From 2941d0f1bcc7571ddbc97a9f8f36f7690ee7ac67 Mon Sep 17 00:00:00 2001 From: dragon Date: Sat, 1 Aug 2026 15:33:54 +0800 Subject: [PATCH 01/16] feat(codespace): add remote development environments Assisted-by: Codex:GPT-5 --- assets/go-licenses.json | 5 + cmd/serv.go | 6 +- custom/conf/app.example.ini | 80 ++ go.mod | 6 +- go.sum | 4 + modelmigration/migrations.go | 1 + modelmigration/v1_28/v346.go | 174 ++++ modelmigration/v1_28/v346_test.go | 114 ++ models/asymkey/ssh_key.go | 68 +- models/asymkey/ssh_key_authorized_keys.go | 15 +- .../asymkey/ssh_key_authorized_keys_test.go | 18 + models/asymkey/ssh_key_deploy.go | 61 +- models/asymkey/ssh_key_fingerprint.go | 8 + models/codespace/codespace.go | 387 +++++++ models/codespace/codespace_test.go | 189 ++++ models/codespace/permission.go | 44 + models/codespace/secret.go | 38 + modules/setting/codespace.go | 90 ++ modules/setting/codespace_test.go | 68 ++ modules/setting/setting.go | 1 + options/locale/locale_en-US.json | 224 ++++ routers/api/codespace/codespace.go | 21 + routers/api/codespace/manager/interceptor.go | 128 +++ routers/api/codespace/manager/main_test.go | 14 + routers/api/codespace/manager/manager.go | 385 +++++++ routers/api/codespace/manager/manager_test.go | 545 ++++++++++ routers/api/v1/api.go | 250 +++-- routers/api/v1/api_test.go | 127 +++ routers/api/v1/repo/pull.go | 96 +- routers/api/v1/user/key.go | 3 +- routers/init.go | 9 + routers/private/serv.go | 70 +- routers/web/codespace/action.go | 224 ++++ routers/web/codespace/action_test.go | 96 ++ routers/web/codespace/create.go | 147 +++ routers/web/codespace/governance.go | 83 ++ routers/web/codespace/log.go | 137 +++ routers/web/codespace/log_test.go | 224 ++++ routers/web/codespace/main_test.go | 14 + routers/web/codespace/open.go | 55 + routers/web/codespace/open_test.go | 164 +++ routers/web/codespace/settings.go | 216 ++++ routers/web/codespace/settings_permission.go | 65 ++ routers/web/codespace/settings_secret.go | 135 +++ routers/web/codespace/view.go | 177 ++++ routers/web/codespace/view_test.go | 201 ++++ routers/web/feed/render.go | 3 +- routers/web/repo/commit.go | 3 + routers/web/repo/download.go | 3 +- routers/web/repo/githttp.go | 2 +- routers/web/repo/issue_view.go | 3 + routers/web/repo/pull.go | 6 + routers/web/repo/view_home.go | 61 +- routers/web/user/home.go | 6 +- routers/web/user/setting/keys.go | 4 +- routers/web/web.go | 65 +- services/asymkey/commit.go | 4 +- services/asymkey/sign.go | 4 +- services/asymkey/sign_test.go | 17 + services/asymkey/ssh_key.go | 7 + services/auth/basic.go | 16 + services/auth/codespace.go | 120 +++ services/auth/codespace_test.go | 284 +++++ services/auth/group.go | 4 + services/auth/oauth2.go | 22 +- services/codespace/configuration.go | 320 ++++++ services/codespace/create.go | 756 ++++++++++++++ services/codespace/create_test.go | 441 ++++++++ services/codespace/devcontainer.go | 348 +++++++ services/codespace/disabled_test.go | 154 +++ services/codespace/fetch.go | 694 +++++++++++++ services/codespace/fetch_test.go | 615 +++++++++++ services/codespace/git_ssh_key.go | 473 +++++++++ services/codespace/git_ssh_key_test.go | 511 +++++++++ services/codespace/governance.go | 369 +++++++ services/codespace/governance_test.go | 116 +++ services/codespace/idle_stop.go | 178 ++++ services/codespace/idle_stop_test.go | 269 +++++ services/codespace/interaction.go | 196 ++++ services/codespace/interaction_test.go | 218 ++++ services/codespace/inventory.go | 317 ++++++ services/codespace/inventory_test.go | 313 ++++++ services/codespace/lifecycle.go | 230 ++++ services/codespace/lifecycle_action.go | 244 +++++ services/codespace/lifecycle_action_test.go | 261 +++++ services/codespace/lifecycle_test.go | 381 +++++++ services/codespace/log.go | 529 ++++++++++ services/codespace/log_test.go | 421 ++++++++ services/codespace/main_test.go | 14 + services/codespace/manager.go | 328 ++++++ services/codespace/manager_gateway.go | 196 ++++ services/codespace/manager_test.go | 301 ++++++ services/codespace/metadata.go | 504 +++++++++ services/codespace/metadata_test.go | 181 ++++ services/codespace/open_token.go | 557 ++++++++++ services/codespace/open_token_test.go | 262 +++++ services/codespace/permission_settings.go | 180 ++++ .../codespace/permission_settings_test.go | 67 ++ services/codespace/public_endpoint.go | 107 ++ services/codespace/public_endpoint_test.go | 202 ++++ services/codespace/reconcile.go | 186 ++++ services/codespace/reconcile_test.go | 118 +++ services/codespace/runtime_transition.go | 164 +++ services/codespace/runtime_transition_test.go | 182 ++++ services/codespace/session_revalidate.go | 140 +++ services/codespace/session_revalidate_test.go | 114 ++ services/codespace/settings.go | 581 +++++++++++ services/codespace/settings_test.go | 231 +++++ services/codespace/ssh_auth.go | 216 ++++ services/codespace/ssh_auth_test.go | 228 ++++ services/codespace/token.go | 473 +++++++++ services/codespace/token_test.go | 359 +++++++ services/codespace/user_delete.go | 150 +++ services/codespace/user_delete_test.go | 79 ++ services/codespace/user_secret.go | 626 +++++++++++ services/codespace/user_secret_test.go | 128 +++ services/codespace/view.go | 572 ++++++++++ services/codespace/view_test.go | 334 ++++++ services/context/api.go | 50 +- services/context/api_test.go | 55 + services/context/context.go | 1 + services/context/permission.go | 49 +- services/context/permission_test.go | 109 ++ services/cron/tasks.go | 1 - services/cron/tasks_basic.go | 25 + services/cron/tasks_extended_test.go | 28 + services/lfs/locks.go | 9 +- services/lfs/server.go | 4 +- services/org/org.go | 47 +- services/repository/codespace_test.go | 109 ++ services/repository/delete.go | 38 + services/repository/transfer.go | 9 +- services/user/user.go | 83 +- services/user/user_test.go | 73 ++ templates/admin/navbar.tmpl | 10 + templates/base/head_navbar.tmpl | 3 + templates/codespace/admin_manager_detail.tmpl | 5 + templates/codespace/admin_managers.tmpl | 5 + templates/codespace/create_confirm.tmpl | 171 +++ templates/codespace/create_source.tmpl | 20 + templates/codespace/detail.tmpl | 35 + templates/codespace/list.tmpl | 43 + templates/codespace/list_rows.tmpl | 73 ++ templates/codespace/manager_codespaces.tmpl | 50 + .../codespace/manager_detail_content.tmpl | 34 + templates/codespace/open_modal.tmpl | 16 + .../codespace/secret_repository_picker.tmpl | 13 + templates/codespace/settings_content.tmpl | 76 ++ templates/codespace/settings_modal.tmpl | 52 + templates/codespace/state.tmpl | 105 ++ templates/codespace/user_manager_detail.tmpl | 3 + templates/codespace/user_permissions.tmpl | 37 + templates/codespace/user_secrets.tmpl | 110 ++ templates/codespace/user_settings.tmpl | 3 + templates/repo/clone_panel.tmpl | 110 +- templates/repo/codespace_panel.tmpl | 46 + templates/repo/codespace_source_button.tmpl | 17 + templates/repo/commit_page.tmpl | 10 + templates/repo/issue/view_title.tmpl | 12 + templates/repo/view_content.tmpl | 6 + templates/user/settings/navbar.tmpl | 8 + tests/integration/actions_trigger_test.go | 42 +- tests/integration/api_admin_test.go | 5 +- tests/integration/api_private_serv_test.go | 64 ++ tests/integration/codespace_test.go | 787 ++++++++++++++ tests/integration/pull_update_test.go | 38 + tools/lint-go-all.go | 25 +- web_src/css/codespace.css | 981 ++++++++++++++++++ web_src/css/index.css | 1 + web_src/css/repo/clone.css | 125 ++- web_src/js/components/ActionRunJobView.vue | 9 +- web_src/js/components/ActionRunView.ts | 95 -- web_src/js/features/codespace.test.ts | 416 ++++++++ web_src/js/features/codespace.ts | 415 ++++++++ web_src/js/features/common-page.ts | 2 + .../features/comp/CodespaceSettings.test.ts | 33 + web_src/js/features/comp/CodespaceSettings.ts | 89 ++ web_src/js/features/repo-common.test.ts | 33 +- web_src/js/features/repo-common.ts | 36 + web_src/js/index.ts | 3 + web_src/js/modules/search.ts | 9 +- .../log.test.ts} | 2 +- web_src/js/render/log.ts | 88 ++ 183 files changed, 26619 insertions(+), 397 deletions(-) create mode 100644 modelmigration/v1_28/v346.go create mode 100644 modelmigration/v1_28/v346_test.go create mode 100644 models/codespace/codespace.go create mode 100644 models/codespace/codespace_test.go create mode 100644 models/codespace/permission.go create mode 100644 models/codespace/secret.go create mode 100644 modules/setting/codespace.go create mode 100644 modules/setting/codespace_test.go create mode 100644 routers/api/codespace/codespace.go create mode 100644 routers/api/codespace/manager/interceptor.go create mode 100644 routers/api/codespace/manager/main_test.go create mode 100644 routers/api/codespace/manager/manager.go create mode 100644 routers/api/codespace/manager/manager_test.go create mode 100644 routers/api/v1/api_test.go create mode 100644 routers/web/codespace/action.go create mode 100644 routers/web/codespace/action_test.go create mode 100644 routers/web/codespace/create.go create mode 100644 routers/web/codespace/governance.go create mode 100644 routers/web/codespace/log.go create mode 100644 routers/web/codespace/log_test.go create mode 100644 routers/web/codespace/main_test.go create mode 100644 routers/web/codespace/open.go create mode 100644 routers/web/codespace/open_test.go create mode 100644 routers/web/codespace/settings.go create mode 100644 routers/web/codespace/settings_permission.go create mode 100644 routers/web/codespace/settings_secret.go create mode 100644 routers/web/codespace/view.go create mode 100644 routers/web/codespace/view_test.go create mode 100644 services/auth/codespace.go create mode 100644 services/auth/codespace_test.go create mode 100644 services/codespace/configuration.go create mode 100644 services/codespace/create.go create mode 100644 services/codespace/create_test.go create mode 100644 services/codespace/devcontainer.go create mode 100644 services/codespace/disabled_test.go create mode 100644 services/codespace/fetch.go create mode 100644 services/codespace/fetch_test.go create mode 100644 services/codespace/git_ssh_key.go create mode 100644 services/codespace/git_ssh_key_test.go create mode 100644 services/codespace/governance.go create mode 100644 services/codespace/governance_test.go create mode 100644 services/codespace/idle_stop.go create mode 100644 services/codespace/idle_stop_test.go create mode 100644 services/codespace/interaction.go create mode 100644 services/codespace/interaction_test.go create mode 100644 services/codespace/inventory.go create mode 100644 services/codespace/inventory_test.go create mode 100644 services/codespace/lifecycle.go create mode 100644 services/codespace/lifecycle_action.go create mode 100644 services/codespace/lifecycle_action_test.go create mode 100644 services/codespace/lifecycle_test.go create mode 100644 services/codespace/log.go create mode 100644 services/codespace/log_test.go create mode 100644 services/codespace/main_test.go create mode 100644 services/codespace/manager.go create mode 100644 services/codespace/manager_gateway.go create mode 100644 services/codespace/manager_test.go create mode 100644 services/codespace/metadata.go create mode 100644 services/codespace/metadata_test.go create mode 100644 services/codespace/open_token.go create mode 100644 services/codespace/open_token_test.go create mode 100644 services/codespace/permission_settings.go create mode 100644 services/codespace/permission_settings_test.go create mode 100644 services/codespace/public_endpoint.go create mode 100644 services/codespace/public_endpoint_test.go create mode 100644 services/codespace/reconcile.go create mode 100644 services/codespace/reconcile_test.go create mode 100644 services/codespace/runtime_transition.go create mode 100644 services/codespace/runtime_transition_test.go create mode 100644 services/codespace/session_revalidate.go create mode 100644 services/codespace/session_revalidate_test.go create mode 100644 services/codespace/settings.go create mode 100644 services/codespace/settings_test.go create mode 100644 services/codespace/ssh_auth.go create mode 100644 services/codespace/ssh_auth_test.go create mode 100644 services/codespace/token.go create mode 100644 services/codespace/token_test.go create mode 100644 services/codespace/user_delete.go create mode 100644 services/codespace/user_delete_test.go create mode 100644 services/codespace/user_secret.go create mode 100644 services/codespace/user_secret_test.go create mode 100644 services/codespace/view.go create mode 100644 services/codespace/view_test.go create mode 100644 services/context/permission_test.go create mode 100644 services/repository/codespace_test.go create mode 100644 templates/codespace/admin_manager_detail.tmpl create mode 100644 templates/codespace/admin_managers.tmpl create mode 100644 templates/codespace/create_confirm.tmpl create mode 100644 templates/codespace/create_source.tmpl create mode 100644 templates/codespace/detail.tmpl create mode 100644 templates/codespace/list.tmpl create mode 100644 templates/codespace/list_rows.tmpl create mode 100644 templates/codespace/manager_codespaces.tmpl create mode 100644 templates/codespace/manager_detail_content.tmpl create mode 100644 templates/codespace/open_modal.tmpl create mode 100644 templates/codespace/secret_repository_picker.tmpl create mode 100644 templates/codespace/settings_content.tmpl create mode 100644 templates/codespace/settings_modal.tmpl create mode 100644 templates/codespace/state.tmpl create mode 100644 templates/codespace/user_manager_detail.tmpl create mode 100644 templates/codespace/user_permissions.tmpl create mode 100644 templates/codespace/user_secrets.tmpl create mode 100644 templates/codespace/user_settings.tmpl create mode 100644 templates/repo/codespace_panel.tmpl create mode 100644 templates/repo/codespace_source_button.tmpl create mode 100644 tests/integration/codespace_test.go create mode 100644 web_src/css/codespace.css create mode 100644 web_src/js/features/codespace.test.ts create mode 100644 web_src/js/features/codespace.ts create mode 100644 web_src/js/features/comp/CodespaceSettings.test.ts create mode 100644 web_src/js/features/comp/CodespaceSettings.ts rename web_src/js/{components/ActionRunView.test.ts => render/log.test.ts} (97%) create mode 100644 web_src/js/render/log.ts diff --git a/assets/go-licenses.json b/assets/go-licenses.json index fbcfc207b98b6..f58108d579fc3 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -1119,6 +1119,11 @@ "path": "github.com/syndtr/goleveldb/LICENSE", "licenseText": "Copyright 2012 Suryandaru Triandana \u003csyndtr@gmail.com\u003e\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above copyright\nnotice, this list of conditions and the following disclaimer in the\ndocumentation and/or other materials provided with the distribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nHOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, + { + "name": "github.com/tailscale/hujson", + "path": "github.com/tailscale/hujson/LICENSE", + "licenseText": "Copyright (c) 2019 Tailscale Inc. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + }, { "name": "github.com/tinylib/msgp", "path": "github.com/tinylib/msgp/LICENSE", diff --git a/cmd/serv.go b/cmd/serv.go index eddbbcb3ab98a..903436ff30a14 100644 --- a/cmd/serv.go +++ b/cmd/serv.go @@ -172,12 +172,16 @@ func runServ(ctx context.Context, c *cli.Command) error { return fail(ctx, "Key check failed", "Failed to check provided key: %v", err) } switch key.Type { + case asymkey_model.KeyTypeUser: + println("Hi there, " + user.Name + "! You've successfully authenticated with the key named " + key.Name + ", but Gitea does not provide shell access.") case asymkey_model.KeyTypeDeploy: println("Hi there! You've successfully authenticated with the deploy key named " + key.Name + ", but Gitea does not provide shell access.") case asymkey_model.KeyTypePrincipal: println("Hi there! You've successfully authenticated with the principal " + key.Content + ", but Gitea does not provide shell access.") + case asymkey_model.KeyTypeCodespace: + println("Hi there! You've successfully authenticated with the codespace key named " + key.Name + ", but Gitea does not provide shell access.") default: - println("Hi there, " + user.Name + "! You've successfully authenticated with the key named " + key.Name + ", but Gitea does not provide shell access.") + return fail(ctx, "Unsupported key type", "Unsupported key type %d for key %d", key.Type, key.ID) } println("If this is unexpected, please log in with password and setup Gitea under another user.") return nil diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 5e518a1be6459..f60d675ebf217 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2188,6 +2188,20 @@ LEVEL = Info ;; Archives created more than OLDER_THAN ago are subject to deletion ;OLDER_THAN = 24h +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Reconcile Codespace operation timeouts and failed retention +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;[cron.reconcile_codespaces] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;ENABLED = true +;RUN_AT_START = true +;NOTICE_ON_SUCCESS = false +;SCHEDULE = @every 1m +;; Failed Codespaces older than OLDER_THAN are deleted locally by Gitea +;OLDER_THAN = 8760h + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Update mirrors @@ -3038,6 +3052,72 @@ LEVEL = Info ;; In a multi-instance deployment the cluster-wide limit is this value times the number of instances. Default value is 16. ;MAX_CONCURRENT_TASK_PICKS = 16 +; [codespace] +;; Enable Codespace integration. +;ENABLED = true +;; +;; Default Git protocol used by new Codespaces when preparing clone URLs. +;; Valid values are `http` and `ssh`; empty values use `http`. +;; The selected protocol must be available when Codespace integration is enabled. +;GIT_PROTOCOL = http +;; +;; Comma-separated SSH known_hosts lines returned to Codespace runtimes for Git SSH clone access. +;; These lines identify the Gitea SSH clone endpoint for strict Host Key checking. +;; Leave empty to use Gitea's configured SSH host keys when the built-in SSH server is enabled. +;; When using an external SSH server, leave empty with GIT_PROTOCOL=http to disable Codespace SSH clone, +;; or set this explicitly before choosing GIT_PROTOCOL=ssh or disabling HTTP Git. +;GIT_SSH_KNOWN_HOSTS = +;; +;; Require Manager-declared Gateway URLs to use HTTPS. +;; This protects browser Gateway session cookies in production deployments. +;GATEWAY_REQUIRE_HTTPS = false +;; +;; Timeout for Gitea-to-Manager control-plane RPC requests. +;CONTROL_PLANE_TIMEOUT = 30s +;; +;; Maximum accepted ManagerService request and response message size. +;; Supports human-readable byte units such as KiB, MiB, and GiB. +;CONTROL_PLANE_MAX_MESSAGE_SIZE = 32MiB +;; +;; Time since the last successful Manager declaration after which the Manager is treated as offline. +;MANAGER_OFFLINE_TIMEOUT = 120s +;; +;; Lease duration granted to a Manager after it claims an operation. +;OPERATION_LEASE_TIMEOUT = 300s +;; +;; Maximum time a running operation may remain active before Gitea times it out. +;OPERATION_MAX_DURATION = 2h +;; +;; Maximum time a queued operation may wait before Gitea applies the queued timeout result. +;QUEUE_TIMEOUT = 5m +;; +;; Lifetime of a one-time Gateway open token issued by Gitea. +;OPEN_TOKEN_EXPIRE = 60s +;; +;; Maximum stored operation log size per Codespace. +;; Gitea uses internal defaults for per-line, page-read, and final-summary reserves so ordinary deployments only tune the total log budget. +;LOG_MAX_SIZE = 64MiB +;; +;; Maximum Runtime Metadata JSON snapshot size accepted from a Manager. +;RUNTIME_METADATA_MAX_SIZE = 256KiB +;; +;; Maximum `devcontainer.json` size read while preparing a Codespace. +;; The limit keeps repository configuration discovery bounded before the Manager clones the repository. +;DEVCONTAINER_CONFIG_MAX_SIZE = 64KiB +;; +;; Base image used to generate the platform default Dev Container configuration when no repository configuration is selected. +;; Keeping this site-wide lets administrators choose an image that is reachable from their Manager deployment. +;DEVCONTAINER_DEFAULT_IMAGE = mcr.microsoft.com/devcontainers/base:ubuntu +;; +;; Default idle timeout for Codespaces that use the site default auto-stop policy. +;AUTO_STOP_DEFAULT_TIMEOUT = 30m +;; +;; Minimum custom idle timeout a user may configure for one Codespace. +;AUTO_STOP_MIN_TIMEOUT = 5m +;; +;; Maximum custom idle timeout a user may configure for one Codespace. +;AUTO_STOP_MAX_TIMEOUT = 168h + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; settings for action logs, will override storage setting diff --git a/go.mod b/go.mod index e905f84838179..28b72a7c9cb91 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module gitea.dev -go 1.26.0 +go 1.26.4 toolchain go1.26.5 @@ -14,6 +14,7 @@ require ( gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 gitea.dev/actions-proto-go v0.6.0 + gitea.dev/codespace-proto-go v0.0.0 gitea.dev/sdk v1.2.0 github.com/42wim/httpsig v1.2.4 github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 @@ -96,6 +97,7 @@ require ( github.com/sergi/go-diff v1.4.0 github.com/stretchr/testify v1.11.1 github.com/syndtr/goleveldb v1.0.0 + github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f github.com/tstranex/u2f v1.0.0 github.com/ulikunitz/xz v0.5.16 github.com/urfave/cli-docs/v3 v3.1.0 @@ -294,3 +296,5 @@ replace github.com/Azure/azure-sdk-for-go/sdk/azcore => github.com/Azure/azure-s replace github.com/Azure/azure-sdk-for-go/sdk/storage/azblob => github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 // v1.6.4+ uses API version unsupported by Azurite in CI replace github.com/microsoft/go-mssqldb => github.com/microsoft/go-mssqldb v1.9.7 // downgraded with Azure SDK + +replace gitea.dev/codespace-proto-go => github.com/ExplodingDragon/codespace-proto-go v0.0.0-20260731162935-a0d955d1e594 diff --git a/go.sum b/go.sum index b8d9fb9af5e2e..e8fd7cc408d7e 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/ExplodingDragon/codespace-proto-go v0.0.0-20260731162935-a0d955d1e594 h1:bvhp7/7tqOTwDBXvKc3EMu5aNmMKFbN86wgvgz6etdg= +github.com/ExplodingDragon/codespace-proto-go v0.0.0-20260731162935-a0d955d1e594/go.mod h1:PtyeQBWy45uIqw8bS6jPK2LYSizpS+W3vtvZ+7E9s10= github.com/Julusian/godocdown v0.0.0-20170816220326-6d19f8ff2df8/go.mod h1:INZr5t32rG59/5xeltqoCJoNY7e5x/3xoY9WSWVWg74= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -689,6 +691,8 @@ github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203 h1:QVqDTf3h2WHt08Yu github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKNihBbwlX8dLpwxCl3+HnXKV/R0e+sRLd9C8= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f h1:9hiVElpCmKzsBKQHkBqZ8LGzt82iLfM8egxr4sew+Ys= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f/go.mod h1:8/zr1Tv0+cKpVtGCEB/7YfRXr2TszsMxMXLaT8YuBgU= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index d7eda29fb1eb7..f2755c8d74475 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -426,6 +426,7 @@ func prepareMigrationTasks() []*migration { newMigration(343, "Add max_parallel column to action_run_job", v1_28.AddMaxParallelToActionRunJob), newMigration(344, "Add deferred-matrix columns to ActionRunJob", v1_28.AddDeferredMatrixColumnsToActionRunJob), newMigration(345, "Add block on CODEOWNERS reviews branch protection", v1_28.AddBlockOnCodeownerReviews), + newMigration(346, "Add Codespace tables", v1_28.AddCodespaceTables), } return preparedMigrations } diff --git a/modelmigration/v1_28/v346.go b/modelmigration/v1_28/v346.go new file mode 100644 index 0000000000000..d5ce5bb3ce4f8 --- /dev/null +++ b/modelmigration/v1_28/v346.go @@ -0,0 +1,174 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_28 + +import ( + "gitea.dev/modelmigration/base" + + "xorm.io/xorm/schemas" +) + +type codespace struct { + UUID string `xorm:"pk CHAR(36)"` + UserID int64 `xorm:"NOT NULL DEFAULT 0"` + RepoID int64 `xorm:"NOT NULL DEFAULT 0"` + RefType string `xorm:"VARCHAR(16) NOT NULL DEFAULT ''"` + RefName string `xorm:"TEXT NOT NULL"` + EnvironmentTag string `xorm:"VARCHAR(64) NOT NULL"` + CommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + DevContainerPath string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"` + DevContainerContentSHA256 string `xorm:"dev_container_content_sha256 CHAR(64) NOT NULL DEFAULT ''"` + DevContainerDefaultImage string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"` + PermissionAuthorizationID int64 `xorm:"NOT NULL DEFAULT 0 index"` + ManagerID int64 `xorm:"NOT NULL DEFAULT 0"` + Status string `xorm:"VARCHAR(16) NOT NULL DEFAULT ''"` + OperationRVersion int64 `xorm:"NOT NULL DEFAULT 0"` + OperationType string `xorm:"VARCHAR(16) NOT NULL DEFAULT ''"` + OperationStatus string `xorm:"VARCHAR(16) NOT NULL DEFAULT ''"` + OperationTrigger string `xorm:"VARCHAR(16) NOT NULL DEFAULT ''"` + OperationCreatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + OperationStartedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + OperationDeadlineUnix int64 `xorm:"NOT NULL DEFAULT 0"` + RuntimeGeneration int64 `xorm:"NOT NULL DEFAULT 0"` + LastActiveUnix int64 `xorm:"NOT NULL DEFAULT 0"` + AutoStopMode string `xorm:"VARCHAR(16) NOT NULL DEFAULT 'default'"` + AutoStopTimeoutSeconds int64 `xorm:"NOT NULL DEFAULT 0"` + InteractionGeneration int64 `xorm:"NOT NULL DEFAULT 0"` + CreatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + UpdatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + LogSize int64 `xorm:"NOT NULL DEFAULT 0"` +} + +func (*codespace) TableName() string { + return "codespace" +} + +func (*codespace) TableIndices() []*schemas.Index { + userUpdated := schemas.NewIndex("user_updated", schemas.IndexType) + userUpdated.AddColumn("user_id", "updated_unix", "created_unix") + + repo := schemas.NewIndex("repo", schemas.IndexType) + repo.AddColumn("repo_id") + + createClaim := schemas.NewIndex("create_claim", schemas.IndexType) + createClaim.AddColumn("status", "operation_type", "operation_status", "manager_id", "environment_tag", "operation_created_unix", "uuid") + + managerActive := schemas.NewIndex("manager_active", schemas.IndexType) + managerActive.AddColumn("manager_id", "operation_status", "operation_type", "status", "operation_created_unix", "uuid") + + queuedTimeout := schemas.NewIndex("queued_timeout", schemas.IndexType) + queuedTimeout.AddColumn("operation_status", "operation_created_unix", "uuid") + + runningTimeout := schemas.NewIndex("running_timeout", schemas.IndexType) + runningTimeout.AddColumn("operation_status", "operation_deadline_unix", "uuid") + + failedRetention := schemas.NewIndex("failed_retention", schemas.IndexType) + failedRetention.AddColumn("status", "updated_unix", "uuid") + + return []*schemas.Index{userUpdated, repo, createClaim, managerActive, queuedTimeout, runningTimeout, failedRetention} +} + +type codespaceManager struct { + ID int64 + Name string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"` + UserID int64 `xorm:"NOT NULL DEFAULT 0"` + SecretHash string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + SecretSalt string `xorm:"VARCHAR(32) NOT NULL DEFAULT ''"` + TagsJSON string `xorm:"TEXT NOT NULL"` + RuntimeState string `xorm:"VARCHAR(16) NOT NULL DEFAULT 'recovering'"` + LastOnlineUnix int64 `xorm:"NOT NULL DEFAULT 0"` + InventoryGeneration int64 `xorm:"NOT NULL DEFAULT 0"` + CreatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + Version string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + GatewaySSHHostKeyAlgorithm string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + GatewaySSHHostKeyFingerprintSHA256 string `xorm:"gateway_ssh_host_key_fingerprint_sha256 VARCHAR(255) NOT NULL DEFAULT ''"` + GatewaySSHHostKeyUpdatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` +} + +func (*codespaceManager) TableName() string { + return "codespace_manager" +} + +func (*codespaceManager) TableIndices() []*schemas.Index { + user := schemas.NewIndex("user", schemas.IndexType) + user.AddColumn("user_id") + return []*schemas.Index{user} +} + +func AddCodespaceTables(x base.EngineMigration) error { + type codespaceManagerAddress struct { + ID int64 + ManagerID int64 `xorm:"NOT NULL DEFAULT 0 unique(manager_kind)"` + Kind string `xorm:"VARCHAR(16) NOT NULL DEFAULT '' unique(manager_kind) unique(kind_address)"` + Address string `xorm:"VARCHAR(512) NOT NULL DEFAULT '' unique(kind_address)"` + } + + type codespaceManagerToken struct { + ID int64 + Token string `xorm:"VARCHAR(64) NOT NULL UNIQUE"` + UserID int64 `xorm:"NOT NULL DEFAULT 0 UNIQUE"` + } + + type codespaceGiteaToken struct { + CodespaceUUID string `xorm:"pk CHAR(36)"` + TokenHash string `xorm:"VARCHAR(100) NOT NULL UNIQUE"` + TokenSalt string `xorm:"VARCHAR(10) NOT NULL"` + TokenLastEight string `xorm:"VARCHAR(8) NOT NULL index"` + TokenEncrypted string `xorm:"TEXT NOT NULL"` + } + + type codespaceSSHKey struct { + CodespaceUUID string `xorm:"pk CHAR(36)"` + KeyID int64 `xorm:"NOT NULL UNIQUE"` + } + + type codespacePermissionAuthorization struct { + ID int64 + UserID int64 `xorm:"NOT NULL index(user_source_request)"` + SourceRepoID int64 `xorm:"NOT NULL index(user_source_request)"` + RequestHash string `xorm:"CHAR(64) NOT NULL index(user_source_request)"` + RevokedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + CreatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + UpdatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + } + + type codespacePermissionRepository struct { + ID int64 + AuthorizationID int64 `xorm:"NOT NULL unique(auth_repo_unit)"` + TargetRepoID int64 `xorm:"NOT NULL index unique(auth_repo_unit)"` + UnitType int `xorm:"NOT NULL unique(auth_repo_unit)"` + RequestedMode int `xorm:"NOT NULL"` + GrantedMode int `xorm:"NOT NULL"` + } + + type codespaceUserSecret struct { + ID int64 + UserID int64 `xorm:"NOT NULL unique(user_name)"` + Name string `xorm:"VARCHAR(255) NOT NULL unique(user_name)"` + DataEncrypted string `xorm:"LONGTEXT NOT NULL"` + DataSize int64 `xorm:"NOT NULL DEFAULT 0"` + AllRepositories bool `xorm:"NOT NULL DEFAULT false"` + CreatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + UpdatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + } + + type codespaceUserSecretRepository struct { + ID int64 + SecretID int64 `xorm:"NOT NULL index unique(secret_repo)"` + RepoID int64 `xorm:"NOT NULL index unique(secret_repo)"` + } + + return x.Sync( + new(codespace), + new(codespaceManager), + new(codespaceManagerAddress), + new(codespaceManagerToken), + new(codespaceGiteaToken), + new(codespaceSSHKey), + new(codespacePermissionAuthorization), + new(codespacePermissionRepository), + new(codespaceUserSecret), + new(codespaceUserSecretRepository), + ) +} diff --git a/modelmigration/v1_28/v346_test.go b/modelmigration/v1_28/v346_test.go new file mode 100644 index 0000000000000..589030a1c5906 --- /dev/null +++ b/modelmigration/v1_28/v346_test.go @@ -0,0 +1,114 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_28 + +import ( + "context" + "slices" + "testing" + + "gitea.dev/modelmigration/migrationtest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/xorm/schemas" +) + +func Test_AddCodespaceTables(t *testing.T) { + x, deferable := migrationtest.PrepareTestEnv(t, 0) + defer deferable() + if x == nil || t.Failed() { + return + } + + require.NoError(t, AddCodespaceTables(x)) + + for _, table := range []string{ + "codespace", + "codespace_manager", + "codespace_manager_address", + "codespace_manager_token", + "codespace_gitea_token", + "codespace_ssh_key", + "codespace_permission_authorization", + "codespace_permission_repository", + "codespace_user_secret", + "codespace_user_secret_repository", + } { + exists, err := x.Dialect().IsTableExist(x.DB(), context.Background(), table) + require.NoError(t, err) + assert.True(t, exists, "table %s should exist", table) + } + + codespaceIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace") + require.NoError(t, err) + assert.True(t, hasIndex(codespaceIndexes, "user_id", "updated_unix", "created_unix")) + assert.True(t, hasIndex(codespaceIndexes, "repo_id")) + assert.True(t, hasIndex(codespaceIndexes, "status", "operation_type", "operation_status", "manager_id", "environment_tag", "operation_created_unix", "uuid")) + assert.True(t, hasIndex(codespaceIndexes, "manager_id", "operation_status", "operation_type", "status", "operation_created_unix", "uuid")) + assert.True(t, hasIndex(codespaceIndexes, "operation_status", "operation_created_unix", "uuid")) + assert.True(t, hasIndex(codespaceIndexes, "operation_status", "operation_deadline_unix", "uuid")) + assert.True(t, hasIndex(codespaceIndexes, "status", "updated_unix", "uuid")) + + managerIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_manager") + require.NoError(t, err) + assert.True(t, hasIndex(managerIndexes, "user_id")) + + addressIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_manager_address") + require.NoError(t, err) + assert.True(t, hasUniqueIndex(addressIndexes, "manager_id", "kind")) + assert.True(t, hasUniqueIndex(addressIndexes, "kind", "address")) + + managerTokenIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_manager_token") + require.NoError(t, err) + assert.True(t, hasUniqueIndex(managerTokenIndexes, "token")) + assert.True(t, hasUniqueIndex(managerTokenIndexes, "user_id")) + + giteaTokenIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_gitea_token") + require.NoError(t, err) + assert.True(t, hasUniqueIndex(giteaTokenIndexes, "token_hash")) + + sshKeyIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_ssh_key") + require.NoError(t, err) + assert.True(t, hasUniqueIndex(sshKeyIndexes, "key_id")) + + authorizationIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_permission_authorization") + require.NoError(t, err) + assert.True(t, hasIndex(authorizationIndexes, "user_id", "source_repo_id", "request_hash")) + + repositoryIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_permission_repository") + require.NoError(t, err) + assert.True(t, hasUniqueIndex(repositoryIndexes, "authorization_id", "target_repo_id", "unit_type")) + + secretIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_user_secret") + require.NoError(t, err) + assert.True(t, hasUniqueIndex(secretIndexes, "user_id", "name")) + _, secretColumns, err := x.Dialect().GetColumns(x.DB(), context.Background(), "codespace_user_secret") + require.NoError(t, err) + assert.Contains(t, secretColumns, "all_repositories") + + secretRepositoryIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_user_secret_repository") + require.NoError(t, err) + assert.True(t, hasIndex(secretRepositoryIndexes, "secret_id")) + assert.True(t, hasIndex(secretRepositoryIndexes, "repo_id")) + assert.True(t, hasUniqueIndex(secretRepositoryIndexes, "secret_id", "repo_id")) +} + +func hasUniqueIndex(indexes map[string]*schemas.Index, columns ...string) bool { + for _, index := range indexes { + if index.Type == schemas.UniqueType && slices.Equal(index.Cols, columns) { + return true + } + } + return false +} + +func hasIndex(indexes map[string]*schemas.Index, columns ...string) bool { + for _, index := range indexes { + if index.Type == schemas.IndexType && slices.Equal(index.Cols, columns) { + return true + } + } + return false +} diff --git a/models/asymkey/ssh_key.go b/models/asymkey/ssh_key.go index e72a88ae8f6c0..1d6109fe04edc 100644 --- a/models/asymkey/ssh_key.go +++ b/models/asymkey/ssh_key.go @@ -14,6 +14,7 @@ import ( "gitea.dev/models/db" "gitea.dev/models/perm" user_model "gitea.dev/models/user" + "gitea.dev/modules/globallock" "gitea.dev/modules/log" "gitea.dev/modules/setting" "gitea.dev/modules/timeutil" @@ -33,9 +34,11 @@ const ( KeyTypeDeploy // KeyTypePrincipal specifies the authorized principal key KeyTypePrincipal + // KeyTypeCodespace specifies a Codespace Git SSH key + KeyTypeCodespace ) -// PublicKey represents a user or deploy SSH public key. +// PublicKey represents a user, deploy, principal, or Codespace SSH public key. type PublicKey struct { ID int64 `xorm:"pk autoincr"` OwnerID int64 `xorm:"INDEX NOT NULL"` @@ -98,37 +101,46 @@ func AddPublicKey(ctx context.Context, ownerID int64, name, content string, auth return nil, err } - return db.WithTx2(ctx, func(ctx context.Context) (*PublicKey, error) { - if err := checkKeyFingerprint(ctx, fingerprint); err != nil { - return nil, err - } + var key *PublicKey + err = globallock.LockAndDo(ctx, PublicKeyFingerprintLockKey(fingerprint), func(ctx context.Context) error { + createdKey, err := db.WithTx2(ctx, func(ctx context.Context) (*PublicKey, error) { + if err := checkKeyFingerprint(ctx, fingerprint); err != nil { + return nil, err + } - // Key name of same user cannot be duplicated. - has, err := db.GetEngine(ctx). - Where("owner_id = ? AND name = ?", ownerID, name). - Get(new(PublicKey)) - if err != nil { - return nil, err - } else if has { - return nil, ErrKeyNameAlreadyUsed{ownerID, name} - } + // Key name of same user cannot be duplicated. + has, err := db.GetEngine(ctx). + Where("owner_id = ? AND name = ?", ownerID, name). + Get(new(PublicKey)) + if err != nil { + return nil, err + } else if has { + return nil, ErrKeyNameAlreadyUsed{ownerID, name} + } - key := &PublicKey{ - OwnerID: ownerID, - Name: name, - Fingerprint: fingerprint, - Content: content, - Mode: perm.AccessModeWrite, - Type: KeyTypeUser, - LoginSourceID: authSourceID, - Verified: verified, - } - if err = addKey(ctx, key); err != nil { - return nil, fmt.Errorf("addKey: %w", err) - } + key := &PublicKey{ + OwnerID: ownerID, + Name: name, + Fingerprint: fingerprint, + Content: content, + Mode: perm.AccessModeWrite, + Type: KeyTypeUser, + LoginSourceID: authSourceID, + Verified: verified, + } + if err = addKey(ctx, key); err != nil { + return nil, fmt.Errorf("addKey: %w", err) + } - return key, nil + return key, nil + }) + if err != nil { + return err + } + key = createdKey + return nil }) + return key, err } // GetPublicKeyByID returns public key by given ID. diff --git a/models/asymkey/ssh_key_authorized_keys.go b/models/asymkey/ssh_key_authorized_keys.go index 8eb5d24c82c3a..427e2696f6cc4 100644 --- a/models/asymkey/ssh_key_authorized_keys.go +++ b/models/asymkey/ssh_key_authorized_keys.go @@ -71,13 +71,14 @@ func writeAuthorizedStringForKey(key *PublicKey, w io.Writer) (keyValid bool, er var sshKey string - if key.Type == KeyTypePrincipal { + switch key.Type { + case KeyTypePrincipal: // TODO: actually using PublicKey to store "principal" is an abuse if !globalVars().principalRegexp.MatchString(key.Content) { return false, fmt.Errorf("invalid principal key: %s", key.Content) } sshKey = fmt.Sprintf("%s # user-%d", key.Content, key.OwnerID) - } else { + case KeyTypeUser, KeyTypeDeploy, KeyTypeCodespace: pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content)) if err != nil { return false, err @@ -85,6 +86,8 @@ func writeAuthorizedStringForKey(key *PublicKey, w io.Writer) (keyValid bool, er sshKeyMarshalled := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey))) sshKey = fmt.Sprintf("%s user-%d", sshKeyMarshalled, key.OwnerID) + default: + return false, fmt.Errorf("unsupported public key type %d", key.Type) } // now the key is valid, the code below could only return template/IO related errors @@ -150,8 +153,12 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error { } for _, key := range keys { - if key.Type == KeyTypePrincipal { + switch key.Type { + case KeyTypeUser, KeyTypeDeploy, KeyTypeCodespace: + case KeyTypePrincipal: continue + default: + return fmt.Errorf("unsupported public key type %d", key.Type) } if err = WriteAuthorizedStringForValidKey(key, f); err != nil { return err @@ -162,7 +169,7 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error { // RegeneratePublicKeys regenerates the authorized_keys file func RegeneratePublicKeys(ctx context.Context, t io.Writer) error { - if err := db.GetEngine(ctx).Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) { + if err := db.GetEngine(ctx).In("type", KeyTypeUser, KeyTypeDeploy, KeyTypeCodespace).Iterate(new(PublicKey), func(idx int, bean any) (err error) { return WriteAuthorizedStringForValidKey(bean.(*PublicKey), t) }); err != nil { return err diff --git a/models/asymkey/ssh_key_authorized_keys_test.go b/models/asymkey/ssh_key_authorized_keys_test.go index 0eb4097f85ee1..5c09615f898e9 100644 --- a/models/asymkey/ssh_key_authorized_keys_test.go +++ b/models/asymkey/ssh_key_authorized_keys_test.go @@ -47,6 +47,16 @@ command="/tmp/gitea --config=/tmp/app.ini serv key-0",no-port-forwarding,no-X11- `) }) + t.Run("CodespaceKey", func(t *testing.T) { + testValid(t, &PublicKey{ + OwnerID: 123, + Content: validKeyContent + " any-comment", + Type: KeyTypeCodespace, + }, `# gitea public key +command="/tmp/gitea --config=/tmp/app.ini serv key-0",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf user-123 +`) + }) + t.Run("PublicKeyWithNewLine", func(t *testing.T) { testValid(t, &PublicKey{ OwnerID: 123, @@ -87,4 +97,12 @@ command="/tmp/gitea --config=/tmp/app.ini serv key-0",no-port-forwarding,no-X11- Type: KeyTypePrincipal, }) }) + + t.Run("UnsupportedType", func(t *testing.T) { + testInvalid(t, &PublicKey{ + OwnerID: 123, + Content: validKeyContent, + Type: KeyType(999), + }) + }) } diff --git a/models/asymkey/ssh_key_deploy.go b/models/asymkey/ssh_key_deploy.go index e7cbc9253eb37..d488b942b546a 100644 --- a/models/asymkey/ssh_key_deploy.go +++ b/models/asymkey/ssh_key_deploy.go @@ -10,6 +10,7 @@ import ( "gitea.dev/models/db" "gitea.dev/models/perm" + "gitea.dev/modules/globallock" "gitea.dev/modules/timeutil" "xorm.io/builder" @@ -117,35 +118,51 @@ func AddDeployKey(ctx context.Context, repoID int64, name, content string, readO accessMode = perm.AccessModeWrite } - return db.WithTx2(ctx, func(ctx context.Context) (*DeployKey, error) { - pkey, exist, err := db.Get[PublicKey](ctx, builder.Eq{"fingerprint": fingerprint}) - if err != nil { - return nil, err - } else if exist { - if pkey.Type != KeyTypeDeploy { - return nil, ErrKeyAlreadyExist{0, fingerprint, ""} + var key *DeployKey + err = globallock.LockAndDo(ctx, PublicKeyFingerprintLockKey(fingerprint), func(ctx context.Context) error { + createdKey, err := db.WithTx2(ctx, func(ctx context.Context) (*DeployKey, error) { + pkeys, err := db.Find[PublicKey](ctx, FindPublicKeyOptions{Fingerprint: fingerprint}) + if err != nil { + return nil, err } - } else { - // First time use this deploy key. - pkey = &PublicKey{ - Fingerprint: fingerprint, - Mode: accessMode, - Type: KeyTypeDeploy, - Content: content, - Name: name, + if len(pkeys) > 1 { + return nil, fmt.Errorf("public key fingerprint %q has multiple rows", fingerprint) } - if err = addKey(ctx, pkey); err != nil { - return nil, fmt.Errorf("addKey: %w", err) + + var pkey *PublicKey + if len(pkeys) == 1 { + pkey = pkeys[0] + if pkey.Type != KeyTypeDeploy { + return nil, ErrKeyAlreadyExist{0, fingerprint, ""} + } + } else { + // First time use this deploy key. + pkey = &PublicKey{ + Fingerprint: fingerprint, + Mode: accessMode, + Type: KeyTypeDeploy, + Content: content, + Name: name, + } + if err = addKey(ctx, pkey); err != nil { + return nil, fmt.Errorf("addKey: %w", err) + } } - } - key, err := addDeployKey(ctx, pkey.ID, repoID, name, pkey.Fingerprint, accessMode) + key, err := addDeployKey(ctx, pkey.ID, repoID, name, pkey.Fingerprint, accessMode) + if err != nil { + return nil, err + } + + return key, nil + }) if err != nil { - return nil, err + return err } - - return key, nil + key = createdKey + return nil }) + return key, err } // GetDeployKeyByID returns deploy key by given ID. diff --git a/models/asymkey/ssh_key_fingerprint.go b/models/asymkey/ssh_key_fingerprint.go index c495275e21082..59b6315c433db 100644 --- a/models/asymkey/ssh_key_fingerprint.go +++ b/models/asymkey/ssh_key_fingerprint.go @@ -5,6 +5,8 @@ package asymkey import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "gitea.dev/models/db" @@ -27,6 +29,12 @@ func checkKeyFingerprint(ctx context.Context, fingerprint string) error { return nil } +// PublicKeyFingerprintLockKey returns the global lock key for one canonical SSH key fingerprint. +func PublicKeyFingerprintLockKey(fingerprint string) string { + digest := sha256.Sum256([]byte(fingerprint)) + return "public_key_fingerprint_" + hex.EncodeToString(digest[:]) +} + func calcFingerprintNative(publicKeyContent string) (string, error) { // Calculate fingerprint. pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(publicKeyContent)) diff --git a/models/codespace/codespace.go b/models/codespace/codespace.go new file mode 100644 index 0000000000000..ca7eb2f34a9d5 --- /dev/null +++ b/models/codespace/codespace.go @@ -0,0 +1,387 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "math" + "strings" + + "gitea.dev/models/db" + "gitea.dev/modules/util" + + "github.com/google/uuid" + "xorm.io/xorm/schemas" +) + +// Codespace persistent lifecycle states. +const ( + StatusCreating = "creating" + StatusRunning = "running" + StatusStopped = "stopped" + StatusDeleting = "deleting" + StatusFailed = "failed" +) + +// Codespace operation types. +const ( + OperationCreate = "create" + OperationResume = "resume" + OperationStop = "stop" + OperationDelete = "delete" +) + +// Codespace operation statuses. +const ( + OperationStatusQueued = "queued" + OperationStatusRunning = "running" +) + +// Codespace operation triggers. +const ( + OperationTriggerUser = "user" + OperationTriggerIdle = "idle" +) + +// Codespace git protocol values. +const ( + GitProtocolHTTP = "http" + GitProtocolSSH = "ssh" +) + +// Codespace auto-stop modes. +const ( + AutoStopModeDefault = "default" + AutoStopModeCustom = "custom" + AutoStopModeNever = "never" +) + +// Manager runtime states persisted by Gitea. +const ( + ManagerRuntimeStateOnline = "online" + ManagerRuntimeStateRecovering = "recovering" +) + +// Manager address kinds. +const ( + ManagerAddressGateway = "gateway" + ManagerAddressSSH = "ssh" +) + +// GiteaTokenAuthDataKey stores the Codespace Token auth snapshot in request data. +const GiteaTokenAuthDataKey = "CodespaceToken" + +// Codespace stores Gitea-owned lifecycle state for one remote development environment. +type Codespace struct { + UUID string `xorm:"pk CHAR(36)"` + UserID int64 `xorm:"NOT NULL DEFAULT 0"` + RepoID int64 `xorm:"NOT NULL DEFAULT 0"` + RefType string `xorm:"VARCHAR(16) NOT NULL DEFAULT ''"` + RefName string `xorm:"TEXT NOT NULL"` + EnvironmentTag string `xorm:"VARCHAR(64) NOT NULL"` + CommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + DevContainerPath string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"` + DevContainerContentSHA256 string `xorm:"dev_container_content_sha256 CHAR(64) NOT NULL DEFAULT ''"` + DevContainerDefaultImage string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"` + PermissionAuthorizationID int64 `xorm:"NOT NULL DEFAULT 0 index"` + ManagerID int64 `xorm:"NOT NULL DEFAULT 0"` + Status string `xorm:"VARCHAR(16) NOT NULL DEFAULT ''"` + OperationRVersion int64 `xorm:"NOT NULL DEFAULT 0"` + OperationType string `xorm:"VARCHAR(16) NOT NULL DEFAULT ''"` + OperationStatus string `xorm:"VARCHAR(16) NOT NULL DEFAULT ''"` + OperationTrigger string `xorm:"VARCHAR(16) NOT NULL DEFAULT ''"` + OperationCreatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + OperationStartedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + OperationDeadlineUnix int64 `xorm:"NOT NULL DEFAULT 0"` + RuntimeGeneration int64 `xorm:"NOT NULL DEFAULT 0"` + LastActiveUnix int64 `xorm:"NOT NULL DEFAULT 0"` + AutoStopMode string `xorm:"VARCHAR(16) NOT NULL DEFAULT 'default'"` + AutoStopTimeoutSeconds int64 `xorm:"NOT NULL DEFAULT 0"` + InteractionGeneration int64 `xorm:"NOT NULL DEFAULT 0"` + CreatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + UpdatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + LogSize int64 `xorm:"NOT NULL DEFAULT 0"` +} + +// Manager stores one registered Manager identity and latest declaration summary. +type Manager struct { + ID int64 + Name string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"` + UserID int64 `xorm:"NOT NULL DEFAULT 0"` + SecretHash string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + SecretSalt string `xorm:"VARCHAR(32) NOT NULL DEFAULT ''"` + TagsJSON string `xorm:"TEXT NOT NULL"` + RuntimeState string `xorm:"VARCHAR(16) NOT NULL DEFAULT 'recovering'"` + LastOnlineUnix int64 `xorm:"NOT NULL DEFAULT 0"` + InventoryGeneration int64 `xorm:"NOT NULL DEFAULT 0"` + CreatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + Version string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + GatewaySSHHostKeyAlgorithm string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + GatewaySSHHostKeyFingerprintSHA256 string `xorm:"gateway_ssh_host_key_fingerprint_sha256 VARCHAR(255) NOT NULL DEFAULT ''"` + GatewaySSHHostKeyUpdatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` +} + +// ManagerAddress stores current routable addresses declared by a Manager. +type ManagerAddress struct { + ID int64 + ManagerID int64 `xorm:"NOT NULL DEFAULT 0 unique(manager_kind)"` + Kind string `xorm:"VARCHAR(16) NOT NULL DEFAULT '' unique(manager_kind) unique(kind_address)"` + Address string `xorm:"VARCHAR(512) NOT NULL DEFAULT '' unique(kind_address)"` +} + +// ManagerToken stores the current site-wide or user-scoped Manager registration token. +type ManagerToken struct { + ID int64 + Token string `xorm:"VARCHAR(64) NOT NULL UNIQUE"` + UserID int64 `xorm:"NOT NULL DEFAULT 0 UNIQUE"` +} + +// GiteaToken stores the current Gitea API/Git HTTP token for one Codespace. +type GiteaToken struct { + CodespaceUUID string `xorm:"pk CHAR(36)"` + TokenHash string `xorm:"VARCHAR(100) NOT NULL UNIQUE"` + TokenSalt string `xorm:"VARCHAR(10) NOT NULL"` + TokenLastEight string `xorm:"VARCHAR(8) NOT NULL index"` + TokenEncrypted string `xorm:"TEXT NOT NULL"` +} + +// SSHKey stores the Git SSH public key binding for one Codespace. +type SSHKey struct { + CodespaceUUID string `xorm:"pk CHAR(36)"` + KeyID int64 `xorm:"NOT NULL UNIQUE"` +} + +func (*Manager) TableName() string { + return "codespace_manager" +} + +// TableIndices returns Codespace indexes in the same order as their main queries. +func (*Codespace) TableIndices() []*schemas.Index { + userUpdated := schemas.NewIndex("user_updated", schemas.IndexType) + userUpdated.AddColumn("user_id", "updated_unix", "created_unix") + + repo := schemas.NewIndex("repo", schemas.IndexType) + repo.AddColumn("repo_id") + + createClaim := schemas.NewIndex("create_claim", schemas.IndexType) + createClaim.AddColumn("status", "operation_type", "operation_status", "manager_id", "environment_tag", "operation_created_unix", "uuid") + + managerActive := schemas.NewIndex("manager_active", schemas.IndexType) + managerActive.AddColumn("manager_id", "operation_status", "operation_type", "status", "operation_created_unix", "uuid") + + queuedTimeout := schemas.NewIndex("queued_timeout", schemas.IndexType) + queuedTimeout.AddColumn("operation_status", "operation_created_unix", "uuid") + + runningTimeout := schemas.NewIndex("running_timeout", schemas.IndexType) + runningTimeout.AddColumn("operation_status", "operation_deadline_unix", "uuid") + + failedRetention := schemas.NewIndex("failed_retention", schemas.IndexType) + failedRetention.AddColumn("status", "updated_unix", "uuid") + + return []*schemas.Index{userUpdated, repo, createClaim, managerActive, queuedTimeout, runningTimeout, failedRetention} +} + +// TableIndices returns Manager indexes in the same order as their main queries. +func (*Manager) TableIndices() []*schemas.Index { + user := schemas.NewIndex("user", schemas.IndexType) + user.AddColumn("user_id") + return []*schemas.Index{user} +} + +func (*ManagerAddress) TableName() string { + return "codespace_manager_address" +} + +func (*ManagerToken) TableName() string { + return "codespace_manager_token" +} + +func (*GiteaToken) TableName() string { + return "codespace_gitea_token" +} + +func (*SSHKey) TableName() string { + return "codespace_ssh_key" +} + +func init() { + db.RegisterModel(new(Codespace)) + db.RegisterModel(new(Manager)) + db.RegisterModel(new(ManagerAddress)) + db.RegisterModel(new(ManagerToken)) + db.RegisterModel(new(GiteaToken)) + db.RegisterModel(new(SSHKey)) +} + +// NewUUID returns a canonical lower-case RFC 4122 UUID v4 string. +func NewUUID() string { + return uuid.NewString() +} + +// GenerateManagerSecret fills the salted Manager secret verifier and returns the plaintext secret. +func (m *Manager) GenerateManagerSecret() string { + secret := hex.EncodeToString(util.CryptoRandomBytes(32)) + m.SecretSalt = hex.EncodeToString(util.CryptoRandomBytes(16)) + m.SecretHash = managerSecretHash(secret, m.SecretSalt) + return secret +} + +// VerifyManagerSecret checks a plaintext Manager secret against the stored verifier. +func (m *Manager) VerifyManagerSecret(secret string) bool { + if m == nil || m.SecretHash == "" || m.SecretSalt == "" || secret == "" { + return false + } + hash := managerSecretHash(secret, m.SecretSalt) + return subtle.ConstantTimeCompare([]byte(hash), []byte(m.SecretHash)) == 1 +} + +// ValidateUUID checks that the input is the canonical lower-case UUID form used by Codespace. +func ValidateUUID(codespaceUUID string) error { + parsed, err := uuid.Parse(codespaceUUID) + if err != nil { + return fmt.Errorf("invalid codespace uuid: %w", err) + } + if parsed.Version() != 4 { + return errors.New("codespace uuid must be version 4") + } + if parsed.String() != codespaceUUID { + return errors.New("codespace uuid must be canonical lower-case form") + } + return nil +} + +// UUID32 returns the 32-character host-safe form derived from a canonical Codespace UUID. +func UUID32(codespaceUUID string) (string, error) { + if err := ValidateUUID(codespaceUUID); err != nil { + return "", err + } + return strings.ReplaceAll(codespaceUUID, "-", ""), nil +} + +// NextVersion returns the next positive version or fails without wrapping. +func NextVersion(current int64) (int64, error) { + if current < 0 { + return 0, errors.New("version must not be negative") + } + if current == math.MaxInt64 { + return 0, errors.New("version exhausted") + } + return current + 1, nil +} + +// ValidateCodespace validates enum-like fields stored on a Codespace row. +func ValidateCodespace(codespace *Codespace) error { + if codespace == nil { + return errors.New("codespace is nil") + } + if err := ValidateUUID(codespace.UUID); err != nil { + return err + } + if !validStatus(codespace.Status) { + return fmt.Errorf("invalid codespace status %q", codespace.Status) + } + if !validAutoStopMode(codespace.AutoStopMode) { + return fmt.Errorf("invalid auto stop mode %q", codespace.AutoStopMode) + } + repositoryConfig := codespace.DevContainerPath != "" || codespace.DevContainerContentSHA256 != "" + defaultConfig := codespace.DevContainerDefaultImage != "" + if repositoryConfig == defaultConfig || (repositoryConfig && (codespace.DevContainerPath == "" || codespace.DevContainerContentSHA256 == "")) { + return errors.New("invalid Dev Container configuration") + } + if codespace.OperationType == "" && codespace.OperationStatus == "" && codespace.OperationTrigger == "" { + return nil + } + if !validOperationType(codespace.OperationType) { + return fmt.Errorf("invalid operation type %q", codespace.OperationType) + } + if !validOperationStatus(codespace.OperationStatus) { + return fmt.Errorf("invalid operation status %q", codespace.OperationStatus) + } + if !validOperationTrigger(codespace.OperationTrigger) { + return fmt.Errorf("invalid operation trigger %q", codespace.OperationTrigger) + } + return nil +} + +// ValidateManager validates enum-like fields stored on a Manager row. +func ValidateManager(manager *Manager) error { + if manager == nil { + return errors.New("manager is nil") + } + if !validManagerRuntimeState(manager.RuntimeState) { + return fmt.Errorf("invalid manager runtime state %q", manager.RuntimeState) + } + return nil +} + +func validStatus(status string) bool { + switch status { + case StatusCreating, StatusRunning, StatusStopped, StatusDeleting, StatusFailed: + return true + default: + return false + } +} + +func validOperationType(operationType string) bool { + switch operationType { + case OperationCreate, OperationResume, OperationStop, OperationDelete: + return true + default: + return false + } +} + +func validOperationStatus(operationStatus string) bool { + switch operationStatus { + case OperationStatusQueued, OperationStatusRunning: + return true + default: + return false + } +} + +func validOperationTrigger(operationTrigger string) bool { + switch operationTrigger { + case OperationTriggerUser, OperationTriggerIdle: + return true + default: + return false + } +} + +func validAutoStopMode(mode string) bool { + switch mode { + case AutoStopModeDefault, AutoStopModeCustom, AutoStopModeNever: + return true + default: + return false + } +} + +func validManagerRuntimeState(state string) bool { + switch state { + case ManagerRuntimeStateOnline, ManagerRuntimeStateRecovering: + return true + default: + return false + } +} + +func managerSecretHash(secret, salt string) string { + secretBytes, secretErr := hex.DecodeString(secret) + saltBytes, saltErr := hex.DecodeString(salt) + if secretErr != nil || saltErr != nil { + return "" + } + payload := make([]byte, 0, len(saltBytes)+len(secretBytes)) + payload = append(payload, saltBytes...) + payload = append(payload, secretBytes...) + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]) +} diff --git a/models/codespace/codespace_test.go b/models/codespace/codespace_test.go new file mode 100644 index 0000000000000..26dbad47bc662 --- /dev/null +++ b/models/codespace/codespace_test.go @@ -0,0 +1,189 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/xorm/schemas" +) + +func TestUUIDValidation(t *testing.T) { + generated := NewUUID() + require.NoError(t, ValidateUUID(generated)) + + uuid32, err := UUID32(generated) + require.NoError(t, err) + assert.Len(t, uuid32, 32) + + assert.Error(t, ValidateUUID("11111111-1111-1111-8111-111111111111")) + assert.Error(t, ValidateUUID("11111111111141118111111111111111")) + assert.Error(t, ValidateUUID("A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11")) +} + +func TestNextVersion(t *testing.T) { + next, err := NextVersion(0) + require.NoError(t, err) + assert.EqualValues(t, 1, next) + + next, err = NextVersion(41) + require.NoError(t, err) + assert.EqualValues(t, 42, next) + + assert.Error(t, func() error { + _, err := NextVersion(-1) + return err + }()) + assert.Error(t, func() error { + _, err := NextVersion(math.MaxInt64) + return err + }()) +} + +func TestManagerSecretVerifier(t *testing.T) { + manager := &Manager{} + secret := manager.GenerateManagerSecret() + assert.Len(t, secret, 64) + assert.Len(t, manager.SecretSalt, 32) + assert.Len(t, manager.SecretHash, 64) + assert.True(t, manager.VerifyManagerSecret(secret)) + assert.False(t, manager.VerifyManagerSecret("bad-secret")) +} + +func TestCodespaceTableIndices(t *testing.T) { + assertIndexColumns(t, (&Codespace{}).TableIndices(), "user_updated", "user_id", "updated_unix", "created_unix") + assertIndexColumns(t, (&Codespace{}).TableIndices(), "repo", "repo_id") + assertIndexColumns(t, (&Codespace{}).TableIndices(), "create_claim", "status", "operation_type", "operation_status", "manager_id", "environment_tag", "operation_created_unix", "uuid") + assertIndexColumns(t, (&Codespace{}).TableIndices(), "manager_active", "manager_id", "operation_status", "operation_type", "status", "operation_created_unix", "uuid") + assertIndexColumns(t, (&Codespace{}).TableIndices(), "queued_timeout", "operation_status", "operation_created_unix", "uuid") + assertIndexColumns(t, (&Codespace{}).TableIndices(), "running_timeout", "operation_status", "operation_deadline_unix", "uuid") + assertIndexColumns(t, (&Codespace{}).TableIndices(), "failed_retention", "status", "updated_unix", "uuid") +} + +func TestManagerTableIndices(t *testing.T) { + assertIndexColumns(t, (&Manager{}).TableIndices(), "user", "user_id") +} + +func TestValidateCodespace(t *testing.T) { + for _, status := range []string{StatusCreating, StatusRunning, StatusStopped, StatusDeleting, StatusFailed} { + t.Run("status/"+status, func(t *testing.T) { + row := validCodespace("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + row.Status = status + require.NoError(t, ValidateCodespace(row)) + }) + } + + for _, operationType := range []string{OperationCreate, OperationResume, OperationStop, OperationDelete} { + t.Run("operation type/"+operationType, func(t *testing.T) { + row := validCodespace("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + row.OperationType = operationType + row.OperationStatus = OperationStatusQueued + row.OperationTrigger = OperationTriggerUser + require.NoError(t, ValidateCodespace(row)) + }) + } + + for _, trigger := range []string{OperationTriggerUser, OperationTriggerIdle} { + t.Run("operation trigger/"+trigger, func(t *testing.T) { + row := validCodespace("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + row.OperationType = OperationCreate + row.OperationStatus = OperationStatusQueued + row.OperationTrigger = trigger + require.NoError(t, ValidateCodespace(row)) + }) + } + + for _, mode := range []string{AutoStopModeDefault, AutoStopModeCustom, AutoStopModeNever} { + t.Run("auto stop mode/"+mode, func(t *testing.T) { + row := validCodespace("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + row.AutoStopMode = mode + require.NoError(t, ValidateCodespace(row)) + }) + } + + row := validCodespace("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + row.Status = "invalid" + assert.Error(t, ValidateCodespace(row)) + + row = validCodespace("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + row.OperationType = OperationCreate + row.OperationStatus = OperationStatusQueued + row.OperationTrigger = OperationTriggerUser + require.NoError(t, ValidateCodespace(row)) + + row.OperationStatus = "leased" + assert.Error(t, ValidateCodespace(row)) + + row = validCodespace("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + row.OperationType = "snapshot" + row.OperationStatus = OperationStatusQueued + row.OperationTrigger = OperationTriggerUser + assert.Error(t, ValidateCodespace(row)) + + row = validCodespace("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + row.OperationType = OperationCreate + row.OperationStatus = OperationStatusQueued + row.OperationTrigger = "timer" + assert.Error(t, ValidateCodespace(row)) + + row = validCodespace("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + row.AutoStopMode = "disabled" + assert.Error(t, ValidateCodespace(row)) + + row = validCodespace("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + row.DevContainerPath = ".devcontainer/devcontainer.json" + assert.Error(t, ValidateCodespace(row)) + + row = validCodespace("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + row.DevContainerDefaultImage = "" + row.DevContainerPath = ".devcontainer/devcontainer.json" + row.DevContainerContentSHA256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + require.NoError(t, ValidateCodespace(row)) +} + +func TestValidateManager(t *testing.T) { + for _, state := range []string{ManagerRuntimeStateOnline, ManagerRuntimeStateRecovering} { + t.Run(state, func(t *testing.T) { + require.NoError(t, ValidateManager(&Manager{RuntimeState: state})) + }) + } + + assert.Error(t, ValidateManager(nil)) + assert.Error(t, ValidateManager(&Manager{RuntimeState: ""})) + assert.Error(t, ValidateManager(&Manager{RuntimeState: "offline"})) +} + +func assertIndexColumns(t *testing.T, indexes []*schemas.Index, name string, columns ...string) { + t.Helper() + for _, index := range indexes { + if index.Name == name { + assert.Equal(t, schemas.IndexType, index.Type) + assert.Equal(t, columns, index.Cols) + return + } + } + assert.Failf(t, "missing index", "index %q was not declared", name) +} + +func validCodespace(codespaceUUID string) *Codespace { + return &Codespace{ + UUID: codespaceUUID, + UserID: 1, + RepoID: 2, + RefType: "branch", + RefName: "main", + EnvironmentTag: "default", + CommitSHA: "0123456789abcdef0123456789abcdef01234567", + DevContainerDefaultImage: "mcr.microsoft.com/devcontainers/base:ubuntu", + Status: StatusCreating, + AutoStopMode: AutoStopModeDefault, + CreatedUnix: 1, + UpdatedUnix: 1, + LogSize: 0, + LastActiveUnix: 0, + } +} diff --git a/models/codespace/permission.go b/models/codespace/permission.go new file mode 100644 index 0000000000000..b6871402d0129 --- /dev/null +++ b/models/codespace/permission.go @@ -0,0 +1,44 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "gitea.dev/models/db" + "gitea.dev/models/perm" + "gitea.dev/models/unit" +) + +// PermissionAuthorization records a user's approval of additional repository access requested by a Codespace configuration. +type PermissionAuthorization struct { + ID int64 + UserID int64 `xorm:"NOT NULL index(user_source_request)"` + SourceRepoID int64 `xorm:"NOT NULL index(user_source_request)"` + RequestHash string `xorm:"CHAR(64) NOT NULL index(user_source_request)"` + RevokedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + CreatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + UpdatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` +} + +// PermissionRepository stores one approved repository unit permission. +type PermissionRepository struct { + ID int64 + AuthorizationID int64 `xorm:"NOT NULL unique(auth_repo_unit)"` + TargetRepoID int64 `xorm:"NOT NULL index unique(auth_repo_unit)"` + UnitType unit.Type `xorm:"NOT NULL unique(auth_repo_unit)"` + RequestedMode perm.AccessMode `xorm:"NOT NULL"` + GrantedMode perm.AccessMode `xorm:"NOT NULL"` +} + +func (*PermissionAuthorization) TableName() string { + return "codespace_permission_authorization" +} + +func (*PermissionRepository) TableName() string { + return "codespace_permission_repository" +} + +func init() { + db.RegisterModel(new(PermissionAuthorization)) + db.RegisterModel(new(PermissionRepository)) +} diff --git a/models/codespace/secret.go b/models/codespace/secret.go new file mode 100644 index 0000000000000..862b24b093658 --- /dev/null +++ b/models/codespace/secret.go @@ -0,0 +1,38 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import "gitea.dev/models/db" + +// UserSecret stores an encrypted Codespace environment variable owned by one user. +type UserSecret struct { + ID int64 + UserID int64 `xorm:"NOT NULL unique(user_name)"` + Name string `xorm:"VARCHAR(255) NOT NULL unique(user_name)"` + DataEncrypted string `xorm:"LONGTEXT NOT NULL"` + DataSize int64 `xorm:"NOT NULL DEFAULT 0"` + AllRepositories bool `xorm:"NOT NULL DEFAULT false"` + CreatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + UpdatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` +} + +// UserSecretRepository grants one repository access to a user-owned Codespace secret. +type UserSecretRepository struct { + ID int64 + SecretID int64 `xorm:"NOT NULL index unique(secret_repo)"` + RepoID int64 `xorm:"NOT NULL index unique(secret_repo)"` +} + +func (*UserSecret) TableName() string { + return "codespace_user_secret" +} + +func (*UserSecretRepository) TableName() string { + return "codespace_user_secret_repository" +} + +func init() { + db.RegisterModel(new(UserSecret)) + db.RegisterModel(new(UserSecretRepository)) +} diff --git a/modules/setting/codespace.go b/modules/setting/codespace.go new file mode 100644 index 0000000000000..8718c2760bae0 --- /dev/null +++ b/modules/setting/codespace.go @@ -0,0 +1,90 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "math" + "strings" + "time" + + "github.com/dustin/go-humanize" +) + +// Codespace contains site-wide defaults for Codespace creation. +var Codespace = struct { + Enabled bool + GitProtocol string + GitSSHKnownHosts []string + GatewayRequireHTTPS bool + ControlPlaneTimeout time.Duration + ControlPlaneMaxSize int64 + ManagerOfflineTimeout time.Duration + OperationLeaseTimeout time.Duration + OperationMaxDuration time.Duration + QueueTimeout time.Duration + OpenTokenExpire time.Duration + LogMaxSize int64 + RuntimeMetadataMaxSize int64 + DevContainerConfigMaxSize int64 + DevContainerDefaultImage string + AutoStopDefaultTimeout time.Duration + AutoStopMinTimeout time.Duration + AutoStopMaxTimeout time.Duration +}{ + Enabled: true, + GitProtocol: "http", + GitSSHKnownHosts: nil, + GatewayRequireHTTPS: false, + ControlPlaneTimeout: 30 * time.Second, + ControlPlaneMaxSize: 32 * 1024 * 1024, + ManagerOfflineTimeout: 120 * time.Second, + OperationLeaseTimeout: 300 * time.Second, + OperationMaxDuration: 2 * time.Hour, + QueueTimeout: 5 * time.Minute, + OpenTokenExpire: 60 * time.Second, + LogMaxSize: 64 * 1024 * 1024, + RuntimeMetadataMaxSize: 256 * 1024, + DevContainerConfigMaxSize: 64 * 1024, + DevContainerDefaultImage: "mcr.microsoft.com/devcontainers/base:ubuntu", + AutoStopDefaultTimeout: 30 * time.Minute, + AutoStopMinTimeout: 5 * time.Minute, + AutoStopMaxTimeout: 168 * time.Hour, +} + +func loadCodespaceFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("codespace") + Codespace.Enabled = sec.Key("ENABLED").MustBool(true) + Codespace.GitSSHKnownHosts = sec.Key("GIT_SSH_KNOWN_HOSTS").Strings(",") + Codespace.GatewayRequireHTTPS = sec.Key("GATEWAY_REQUIRE_HTTPS").MustBool(false) + Codespace.ControlPlaneTimeout = sec.Key("CONTROL_PLANE_TIMEOUT").MustDuration(30 * time.Second) + Codespace.ControlPlaneMaxSize = mustCodespaceBytes(sec, "CONTROL_PLANE_MAX_MESSAGE_SIZE", "32MiB") + Codespace.ManagerOfflineTimeout = sec.Key("MANAGER_OFFLINE_TIMEOUT").MustDuration(120 * time.Second) + Codespace.OperationLeaseTimeout = sec.Key("OPERATION_LEASE_TIMEOUT").MustDuration(300 * time.Second) + Codespace.OperationMaxDuration = sec.Key("OPERATION_MAX_DURATION").MustDuration(2 * time.Hour) + Codespace.QueueTimeout = sec.Key("QUEUE_TIMEOUT").MustDuration(5 * time.Minute) + Codespace.OpenTokenExpire = sec.Key("OPEN_TOKEN_EXPIRE").MustDuration(60 * time.Second) + Codespace.LogMaxSize = mustCodespaceBytes(sec, "LOG_MAX_SIZE", "64MiB") + Codespace.RuntimeMetadataMaxSize = mustCodespaceBytes(sec, "RUNTIME_METADATA_MAX_SIZE", "256KiB") + Codespace.DevContainerConfigMaxSize = mustCodespaceBytes(sec, "DEVCONTAINER_CONFIG_MAX_SIZE", "64KiB") + Codespace.DevContainerDefaultImage = strings.TrimSpace(sec.Key("DEVCONTAINER_DEFAULT_IMAGE").MustString("mcr.microsoft.com/devcontainers/base:ubuntu")) + Codespace.AutoStopDefaultTimeout = sec.Key("AUTO_STOP_DEFAULT_TIMEOUT").MustDuration(30 * time.Minute) + Codespace.AutoStopMinTimeout = sec.Key("AUTO_STOP_MIN_TIMEOUT").MustDuration(5 * time.Minute) + Codespace.AutoStopMaxTimeout = sec.Key("AUTO_STOP_MAX_TIMEOUT").MustDuration(168 * time.Hour) + protocol := strings.ToLower(strings.TrimSpace(sec.Key("GIT_PROTOCOL").MustString("http"))) + switch protocol { + case "http", "ssh": + Codespace.GitProtocol = protocol + default: + Codespace.GitProtocol = "http" + } +} + +func mustCodespaceBytes(section ConfigSection, key, defaultValue string) int64 { + value := section.Key(key).MustString(defaultValue) + bytes, err := humanize.ParseBytes(value) + if err != nil || bytes > math.MaxInt64 { + return -1 + } + return int64(bytes) +} diff --git a/modules/setting/codespace_test.go b/modules/setting/codespace_test.go new file mode 100644 index 0000000000000..a738e4e3a9502 --- /dev/null +++ b/modules/setting/codespace_test.go @@ -0,0 +1,68 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadCodespaceFrom(t *testing.T) { + cfg, err := NewConfigProviderFromData(` +[codespace] +ENABLED = false +GIT_PROTOCOL = ssh +GIT_SSH_KNOWN_HOSTS = gitea.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH6Y4idVaW3E+bLw1uqoAfJD7o5Siu+HqS51E9oQLPE9,[gitea.example.com]:2222 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCtest +GATEWAY_REQUIRE_HTTPS = true +CONTROL_PLANE_TIMEOUT = 10s +CONTROL_PLANE_MAX_MESSAGE_SIZE = 33MiB +MANAGER_OFFLINE_TIMEOUT = 80s +OPERATION_LEASE_TIMEOUT = 1500ms +OPERATION_MAX_DURATION = 3h +QUEUE_TIMEOUT = 7m +OPEN_TOKEN_EXPIRE = 45s +LOG_MAX_SIZE = 32MiB +RUNTIME_METADATA_MAX_SIZE = 128KiB +DEVCONTAINER_CONFIG_MAX_SIZE = 96KiB +DEVCONTAINER_DEFAULT_IMAGE = registry.example.com/devcontainer:latest +AUTO_STOP_DEFAULT_TIMEOUT = 25m +AUTO_STOP_MIN_TIMEOUT = 3m +AUTO_STOP_MAX_TIMEOUT = 24h +`) + require.NoError(t, err) + loadCodespaceFrom(cfg) + assert.False(t, Codespace.Enabled) + assert.Equal(t, "ssh", Codespace.GitProtocol) + assert.Len(t, Codespace.GitSSHKnownHosts, 2) + assert.True(t, Codespace.GatewayRequireHTTPS) + assert.Equal(t, 10*time.Second, Codespace.ControlPlaneTimeout) + assert.EqualValues(t, 33*1024*1024, Codespace.ControlPlaneMaxSize) + assert.Equal(t, 80*time.Second, Codespace.ManagerOfflineTimeout) + assert.Equal(t, 1500*time.Millisecond, Codespace.OperationLeaseTimeout) + assert.Equal(t, 3*time.Hour, Codespace.OperationMaxDuration) + assert.Equal(t, 7*time.Minute, Codespace.QueueTimeout) + assert.Equal(t, 45*time.Second, Codespace.OpenTokenExpire) + assert.EqualValues(t, 32*1024*1024, Codespace.LogMaxSize) + assert.EqualValues(t, 128*1024, Codespace.RuntimeMetadataMaxSize) + assert.EqualValues(t, 96*1024, Codespace.DevContainerConfigMaxSize) + assert.Equal(t, "registry.example.com/devcontainer:latest", Codespace.DevContainerDefaultImage) + assert.Equal(t, 25*time.Minute, Codespace.AutoStopDefaultTimeout) + assert.Equal(t, 3*time.Minute, Codespace.AutoStopMinTimeout) + assert.Equal(t, 24*time.Hour, Codespace.AutoStopMaxTimeout) + + cfg, err = NewConfigProviderFromData(` +[codespace] +GIT_PROTOCOL = invalid +`) + require.NoError(t, err) + loadCodespaceFrom(cfg) + assert.True(t, Codespace.Enabled) + assert.Equal(t, "http", Codespace.GitProtocol) + assert.False(t, Codespace.GatewayRequireHTTPS) + assert.Equal(t, 30*time.Second, Codespace.ControlPlaneTimeout) + assert.EqualValues(t, 32*1024*1024, Codespace.ControlPlaneMaxSize) +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index a20c202828ad5..02fd96b91c234 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -147,6 +147,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { if err := loadActionsFrom(cfg); err != nil { return err } + loadCodespaceFrom(cfg) loadUIFrom(cfg) loadAdminFrom(cfg) loadAPIFrom(cfg) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 6ac76d8d45a64..bdb5b45f80a53 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -137,6 +137,7 @@ "download_logs": "Download logs", "confirm_delete_selected": "Confirm to delete all selected items?", "name": "Name", + "status": "Status", "value": "Value", "readme": "Readme", "filter_title": "Filter", @@ -2980,6 +2981,229 @@ "admin.dashboard.sync_external_users": "Synchronize external user data", "admin.dashboard.cleanup_hook_task_table": "Clean up hook_task table", "admin.dashboard.cleanup_packages": "Clean up expired packages", + "admin.dashboard.reconcile_codespaces": "Reconcile Codespaces", + "codespace.access": "Access", + "codespace.access_unavailable": "Access becomes available after the runtime is ready.", + "codespace.additional_permissions_explanation": "The Codespace receives the repository access shown below. Access requested by the Dev Container can be reduced before creation.", + "codespace.authenticated_endpoint": "Authenticated", + "codespace.auto_stop": "Auto-stop", + "codespace.auto_stop_custom": "Custom", + "codespace.auto_stop_custom_desc": "Use an idle timeout for this Codespace.", + "codespace.auto_stop_default": "Site default", + "codespace.auto_stop_default_desc": "Stop after %s of inactivity. This follows the site setting.", + "codespace.auto_stop_desc": "Choose how long this Codespace keeps running after authenticated activity ends.", + "codespace.auto_stop_effective": "Current behavior: stop after %s of inactivity.", + "codespace.auto_stop_effective_never": "Current behavior: keep running until stopped manually.", + "codespace.auto_stop_invalid_duration": "Enter a positive whole duration and select a valid unit.", + "codespace.auto_stop_invalid_range": "The custom idle timeout must be within the site range shown below.", + "codespace.auto_stop_mode": "Mode", + "codespace.auto_stop_never": "Never", + "codespace.auto_stop_never_desc": "Keep this Codespace running until it is stopped manually.", + "codespace.auto_stop_out_of_range": "The saved custom timeout is outside the current site range. Select a value within the range before saving it again.", + "codespace.auto_stop_range": "Allowed range: %s to %s.", + "codespace.auto_stop_timeout": "Idle timeout", + "codespace.auto_stop_timeout_unit": "Idle timeout unit", + "codespace.auto_stop_unavailable": "Auto-stop settings become available after the runtime has been created.", + "codespace.auto_stop_unit_days": "days", + "codespace.auto_stop_unit_hours": "hours", + "codespace.auto_stop_unit_minutes": "minutes", + "codespace.auto_stop_unit_seconds": "seconds", + "codespace.back_to_list": "Back to Codespaces", + "codespace.back_to_managers": "Back to Codespace Managers", + "codespace.boot_stage.initialize_system": "Initialize system", + "codespace.boot_stage.prepare_runtime": "Prepare runtime", + "codespace.boot_stage.prepare_workspace": "Prepare workspace", + "codespace.boot_stage.publish_ready": "Publish runtime access", + "codespace.boot_stage.ready": "Finalize startup", + "codespace.boot_stage.start_environment": "Start development environment", + "codespace.bound_codespaces": "Bound Codespaces", + "codespace.clone": "Clone", + "codespace.codespace": "Codespace", + "codespace.commit": "Commit", + "codespace.commit_empty": "No codespaces for this commit.", + "codespace.confirm_and_create": "Confirm and create", + "codespace.confirm_create": "Confirm Codespace creation", + "codespace.continue": "Keep running", + "codespace.cpu": "CPU", + "codespace.created": "Created", + "codespace.create": "Create Codespace", + "codespace.current_ref": "Current ref", + "codespace.current_stage": "Current stage", + "codespace.create_unavailable": "Codespace creation is unavailable", + "codespace.creator": "Creator", + "codespace.delete_confirm": "Delete Codespace %s? Its workspace, credentials, and operation logs will be removed.", + "codespace.delete_detail_confirm": "Delete this Codespace? Its workspace, credentials, and operation logs will be removed.", + "codespace.delete_title": "Delete Codespace", + "codespace.dev_container_configuration": "Dev Container configuration", + "codespace.dev_container_platform_default": "Platform default", + "codespace.development_environment": "Development environment", + "codespace.detached_snapshot": "Detached snapshot", + "codespace.disk": "Disk", + "codespace.details": "Details", + "codespace.download_logs": "Download logs", + "codespace.empty": "No Codespaces", + "codespace.empty_desc": "Create one from a repository code page.", + "codespace.empty_org_desc": "You have not created any Codespaces from repositories owned by %s.", + "codespace.endpoints": "Endpoints", + "codespace.environment": "Environment", + "codespace.environment_select": "Select an environment", + "codespace.environment_offline_queue": "This environment is currently offline. Creation will remain queued until a matching Manager is available.", + "codespace.environment_source_both": "Site and personal", + "codespace.environment_source_personal": "Personal", + "codespace.environment_source_site": "Site", + "codespace.environment_unavailable": "No Codespace environment is currently configured for your account. Ask an administrator to register a site Manager or register a personal Manager.", + "codespace.environment_description_conflict": "Managers declare different descriptions for these environment tags: %s. Creation remains available, but the descriptions should be aligned.", + "codespace.enter_fullscreen": "Enter fullscreen", + "codespace.error.confirm_required": "Confirm the action before submitting.", + "codespace.error.environment_unavailable": "Select an available Codespace environment and try again.", + "codespace.error.create_request_changed": "The repository configuration changed. Review the updated permissions and secrets before creating the Codespace.", + "codespace.error.invalid_create_request": "Codespace could not be created from this repository and ref.", + "codespace.error.invalid_request": "The Codespace request is invalid.", + "codespace.error.permission_denied": "You do not have permission to use this Codespace.", + "codespace.error.state_unavailable": "The Codespace is not ready for that action. Refresh the page for the current state.", + "codespace.error.version_exhausted": "The Codespace has too many lifecycle changes. Delete it and create a new one.", + "codespace.exit_fullscreen": "Exit fullscreen", + "codespace.force_delete": "Force delete", + "codespace.force_delete_confirm": "Force delete Codespace %s? Its Gitea record will be removed immediately. The Manager will clean up remaining runtime resources during reconciliation.", + "codespace.force_delete_title": "Force delete Codespace", + "codespace.gateway": "Gateway", + "codespace.governance": "Codespace Governance", + "codespace.last_active": "Last active", + "codespace.last_online": "Last online", + "codespace.limit_unknown": "Limit unknown", + "codespace.list_desc": "Your remote development environments.", + "codespace.log_polling_hint": "Logs update while a lifecycle operation is running.", + "codespace.logs": "Logs", + "codespace.manager": "Manager", + "codespace.manager_id": "ID", + "codespace.manager_delete_confirm": "Delete Manager %s? The Manager identity and all Gitea Codespace records bound to it will be removed.", + "codespace.manager_delete_notice": "Deleting this Manager removes its identity and %d bound Codespace records from Gitea. Runtime resources are reconciled separately.", + "codespace.manager_delete_title": "Delete Codespace Manager", + "codespace.manager_ownership_conflict": "This Manager has a Codespace outside your account and cannot be deleted.", + "codespace.manager_status.offline": "Offline", + "codespace.manager_status.online": "Online", + "codespace.manager_status.recovering": "Recovering", + "codespace.managers": "Codespace Managers", + "codespace.memory": "Memory", + "codespace.new_manager": "New", + "codespace.not_declared": "Not declared", + "codespace.no_log_lines": "No log lines yet.", + "codespace.log_load_failed": "The operation log could not be loaded.", + "codespace.no_managers": "No Codespace Managers.", + "codespace.no_bound_codespaces": "No Codespaces are bound to this Manager.", + "codespace.no_unassigned_codespaces": "No unassigned Codespaces require attention.", + "codespace.no_permission_authorizations": "No additional repository permissions have been authorized.", + "codespace.no_secrets": "No Codespace secrets have been configured.", + "codespace.observed": "Observed", + "codespace.overview": "Overview", + "codespace.open": "Open", + "codespace.open_codespace": "Open Codespace", + "codespace.open_confirm": "The selected runtime access point will open through the Codespace Gateway.", + "codespace.open.unavailable": "Codespace endpoint is not currently available.", + "codespace.open_workspace": "Open Workspace", + "codespace.operation_log": "Operation log", + "codespace.port": "Port", + "codespace.pull_empty": "No codespaces for this pull request.", + "codespace.pull_request_source": "Pull Request #%d: %s:%s into %s", + "codespace.pull_request_source_permission": "Required for the pull request branch; current Gitea permissions still apply.", + "codespace.pull_request_snapshot": "Pull Request #%d into %s", + "codespace.ports": "Ports", + "codespace.permission_codespace_count": "%d Codespaces use this authorization", + "codespace.permission_revoke_confirm": "Revoke this authorization? Codespaces using it will immediately lose these additional repository permissions.", + "codespace.permission_revoked": "Revoked", + "codespace.permissions": "Repository permissions", + "codespace.permissions_description": "These authorizations were approved when creating Codespaces. You can reduce or revoke access here. A selected Dev Container configuration must request and you must confirm any later increase.", + "codespace.permissions_updated": "Codespace repository permissions have been updated.", + "codespace.user": "User", + "codespace.public_endpoint": "Public", + "codespace.ref": "Ref", + "codespace.recent": "Recent Codespaces", + "codespace.recommended_secrets": "Recommended secrets", + "codespace.recommended_secrets_description": "The selected Dev Container recommends these environment variables. Existing values remain hidden. You can allow this repository or provide an optional missing value before creation.", + "codespace.registration_token": "Registration token", + "codespace.registration_token_reset": "Registration token has been reset.", + "codespace.repo_empty": "No codespaces for this repository.", + "codespace.repository": "Repository", + "codespace.repository_access": "Repository access", + "codespace.repository_unavailable": "Repository unavailable", + "codespace.requested": "Requested", + "codespace.granted": "Granted", + "codespace.reduce_to_read": "Reduce to read", + "codespace.revoke": "Revoke", + "codespace.reset_registration_token": "Reset registration token", + "codespace.reset_registration_token_confirm": "Reset registration token?", + "codespace.resume": "Resume", + "codespace.resume_confirm": "The runtime environment will start and become available when initialization completes.", + "codespace.resume_title": "Resume this Codespace?", + "codespace.runtime_usage": "Runtime usage", + "codespace.runtime_environment": "Runtime environment", + "codespace.secret_delete_confirm": "Remove secret %s? Its value will no longer be available after Codespaces are created or restarted.", + "codespace.secret_allow_repository": "Allow this repository", + "codespace.secret_all_repositories": "All repositories", + "codespace.secret_count_limit": "You can configure up to 100 Codespace secrets.", + "codespace.secret_new_value": "New secret value", + "codespace.secret_name_conflict": "A Codespace secret with this name already exists.", + "codespace.secret_name_invalid": "Use a valid, non-reserved environment variable name.", + "codespace.secret_no_repositories": "This secret is not available to any repository.", + "codespace.secret_optional_value": "Optional value", + "codespace.secret_repository_access": "Repository access", + "codespace.secret_repository_count": "%d selected repositories", + "codespace.secret_repository_permission_required": "Code write access is required for every selected repository.", + "codespace.secret_replace_value": "Replace value", + "codespace.secret_size_limit": "The Codespace secrets available to a repository cannot exceed 512 KiB.", + "codespace.secret_update_failed": "The Codespace secret could not be updated. Check its name, value, repository access, and size limits.", + "codespace.secret_updated": "Codespace secret settings have been updated.", + "codespace.secret_value_hidden": "Value is hidden", + "codespace.secret_value_invalid": "Enter a non-empty UTF-8 value no larger than 48 KiB.", + "codespace.secrets": "Codespace secrets", + "codespace.secrets_available_description": "These secret names are currently authorized for this repository. Values remain hidden.", + "codespace.secrets_fork_pull_unavailable": "User Codespace secrets are not provided to Codespaces created from fork pull requests.", + "codespace.secrets_manage": "Manage Codespace secrets", + "codespace.secrets_none_for_create": "No Codespace secrets are currently available to this repository.", + "codespace.secrets_write_access_required": "Codespace secrets require code write access to the source repository.", + "codespace.service": "Service", + "codespace.source": "Source", + "codespace.source_repository_permission_explanation": "The Codespace can access this source repository with your current repository permissions. No additional repository access was requested.", + "codespace.ssh": "SSH", + "codespace.ssh_host_key": "SSH host key", + "codespace.ssh_host_key_algorithm": "Algorithm", + "codespace.ssh_host_key_fingerprint": "SHA256 fingerprint", + "codespace.ssh_host_key_updated": "Host key updated", + "codespace.ssh_verify_host_key": "Verify SSH host key", + "codespace.tags": "Tags", + "codespace.delete_manager": "Delete Manager", + "codespace.status.booting": "Booting", + "codespace.status.deleting": "Deleting", + "codespace.status.failed": "Failed", + "codespace.status.metadata_rebuilding": "Rebuilding metadata", + "codespace.status.queued": "Queued", + "codespace.status.recovering": "Recovering", + "codespace.status.resuming": "Resuming", + "codespace.status.running": "Running", + "codespace.status.stopped": "Stopped", + "codespace.status.stopping": "Stopping", + "codespace.status_summary.booting": "Creating the runtime.", + "codespace.status_summary.deleting": "Deleting runtime resources.", + "codespace.status_summary.failed": "The lifecycle operation failed. Check the log below.", + "codespace.status_summary.metadata_rebuilding": "Runtime metadata is being rebuilt. Workspace and endpoints will return when ready.", + "codespace.status_summary.queued": "Waiting for a Codespace Manager.", + "codespace.status_summary.recovering": "Waiting for the Manager to become available.", + "codespace.status_summary.resuming": "Resuming the runtime.", + "codespace.status_summary.running": "Ready for interactive use.", + "codespace.status_summary.stopped": "Stopped. Resume it to reconnect.", + "codespace.status_summary.stopping": "Stopping the runtime.", + "codespace.stop": "Stop", + "codespace.stop_confirm": "Active connections and running processes will end. You can resume this Codespace later.", + "codespace.stop_title": "Stop this Codespace?", + "codespace.title": "Codespaces", + "codespace.updated": "Updated", + "codespace.unassigned_codespaces": "Unassigned Codespaces", + "codespace.user_id": "User %d", + "codespace.visibility": "Visibility", + "codespace.view_all": "View all Codespaces", + "codespace.waiting_for_manager": "Waiting for Manager", + "codespace.workspace": "Workspace", + "codespace.workspace_desc": "Open the built-in workspace terminal or the runtime workspace endpoint.", "admin.dashboard.cleanup_actions": "Clean up expired actions' resources", "admin.dashboard.server_uptime": "Server Uptime", "admin.dashboard.current_goroutine": "Current Goroutines", diff --git a/routers/api/codespace/codespace.go b/routers/api/codespace/codespace.go new file mode 100644 index 0000000000000..40734d3b8209b --- /dev/null +++ b/routers/api/codespace/codespace.go @@ -0,0 +1,21 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "net/http" + + "gitea.dev/modules/web" + "gitea.dev/routers/api/codespace/manager" +) + +// Routes returns Codespace control-plane API routes. +func Routes(prefix string) *web.Router { + m := web.NewRouter() + + path, handler := manager.NewManagerServiceHandler() + m.Post(path+"*", http.StripPrefix(prefix, handler).ServeHTTP) + + return m +} diff --git a/routers/api/codespace/manager/interceptor.go b/routers/api/codespace/manager/interceptor.go new file mode 100644 index 0000000000000..67819c16861e5 --- /dev/null +++ b/routers/api/codespace/manager/interceptor.go @@ -0,0 +1,128 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package manager + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + "gitea.dev/codespace-proto-go/codespace/v1/codespacev1connect" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/modules/setting" + codespace_service "gitea.dev/services/codespace" + + "connectrpc.com/connect" +) + +const ( + managerIDHeader = "x-codespace-manager-id" + managerSecretHeader = "x-codespace-manager-secret" + protocolVersion = 1 +) + +type managerCtxKey struct{} + +type versionedRequest interface { + GetProtocolVersion() int32 +} + +var withManager = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) { + if request.Spec().Procedure != codespacev1connect.ManagerServiceRegisterManagerProcedure { + manager, err := authenticate(ctx, request.Header()) + if err != nil { + switch { + case errors.Is(err, codespace_service.ErrManagerUnregistered): + return nil, failureError(connect.CodeUnauthenticated, "manager_unregistered", err) + default: + return nil, failureError(connect.CodeUnauthenticated, "unauthenticated", err) + } + } + ctx = context.WithValue(ctx, managerCtxKey{}, manager) + } + if err := validateProtocolVersion(request.Any()); err != nil { + return nil, failureError(connect.CodeFailedPrecondition, "protocol_mismatch", err) + } + ctx, cancel := context.WithTimeout(ctx, setting.Codespace.ControlPlaneTimeout) + defer cancel() + response, err := unaryFunc(ctx, request) + if err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) { + return nil, connect.NewError(connect.CodeDeadlineExceeded, context.DeadlineExceeded) + } + if errors.Is(ctx.Err(), context.Canceled) || errors.Is(err, context.Canceled) { + return nil, connect.NewError(connect.CodeCanceled, context.Canceled) + } + } + return response, err + } +})) + +// GetManager returns the Manager authenticated before every RPC except RegisterManager. +func GetManager(ctx context.Context) *codespace_model.Manager { + if value := ctx.Value(managerCtxKey{}); value != nil { + if manager, ok := value.(*codespace_model.Manager); ok { + return manager + } + } + return nil +} + +func authenticate(ctx context.Context, header http.Header) (*codespace_model.Manager, error) { + managerID, err := strconv.ParseInt(header.Get(managerIDHeader), 10, 64) + if err != nil { + return nil, errors.New("invalid manager id") + } + manager, err := codespace_service.AuthenticateManager(ctx, managerID, header.Get(managerSecretHeader)) + if err != nil { + return nil, err + } + return manager, nil +} + +func validateProtocolVersion(message any) error { + request, ok := message.(versionedRequest) + if !ok { + return errors.New("request does not carry protocol version") + } + if request.GetProtocolVersion() != protocolVersion { + return fmt.Errorf("unsupported protocol version %d", request.GetProtocolVersion()) + } + return nil +} + +func failureError(code connect.Code, category string, err error) error { + return failureErrorBase(code, category, err) +} + +func failureErrorBase(code connect.Code, category string, err error) *connect.Error { + connectErr := connect.NewError(code, err) + detail, detailErr := connect.NewErrorDetail(&codespacev1.FailureDetail{Category: category}) + if detailErr == nil { + connectErr.AddDetail(detail) + } + return connectErr +} + +func failureErrorWithStaleGeneration(code connect.Code, category string, currentGeneration int64, err error) error { + connectErr := failureErrorBase(code, category, err) + staleDetail, staleDetailErr := connect.NewErrorDetail(&codespacev1.StaleGenerationDetail{CurrentGeneration: currentGeneration}) + if staleDetailErr == nil { + connectErr.AddDetail(staleDetail) + } + return connectErr +} + +func failureErrorWithLogOffset(code connect.Code, category string, currentOffset int64, err error) error { + connectErr := failureErrorBase(code, category, err) + offsetDetail, offsetDetailErr := connect.NewErrorDetail(&codespacev1.LogOffsetDetail{CurrentOffset: currentOffset}) + if offsetDetailErr == nil { + connectErr.AddDetail(offsetDetail) + } + return connectErr +} diff --git a/routers/api/codespace/manager/main_test.go b/routers/api/codespace/manager/main_test.go new file mode 100644 index 0000000000000..f4fee2e3b91fa --- /dev/null +++ b/routers/api/codespace/manager/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package manager + +import ( + "testing" + + "gitea.dev/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/api/codespace/manager/manager.go b/routers/api/codespace/manager/manager.go new file mode 100644 index 0000000000000..1f792fafddb2e --- /dev/null +++ b/routers/api/codespace/manager/manager.go @@ -0,0 +1,385 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package manager + +import ( + "context" + "errors" + "net/http" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + "gitea.dev/codespace-proto-go/codespace/v1/codespacev1connect" + "gitea.dev/modules/setting" + codespace_service "gitea.dev/services/codespace" + + "connectrpc.com/connect" +) + +// NewManagerServiceHandler returns the Codespace ManagerService Connect handler. +func NewManagerServiceHandler() (string, http.Handler) { + return codespacev1connect.NewManagerServiceHandler( + &Service{}, + connect.WithCompressMinBytes(1024), + connect.WithReadMaxBytes(int(setting.Codespace.ControlPlaneMaxSize)), + connect.WithSendMaxBytes(int(setting.Codespace.ControlPlaneMaxSize)), + withManager, + ) +} + +var _ codespacev1connect.ManagerServiceHandler = (*Service)(nil) + +// Service implements the Codespace ManagerService RPC entrypoint. +type Service struct { + codespacev1connect.UnimplementedManagerServiceHandler +} + +// RegisterManager exchanges a registration token for a Manager identity. +func (s *Service) RegisterManager( + ctx context.Context, + req *connect.Request[codespacev1.RegisterManagerRequest], +) (*connect.Response[codespacev1.RegisterManagerResponse], error) { + manager, secret, err := codespace_service.RegisterManager(ctx, req.Msg.GetRegistrationToken()) + if err != nil { + return nil, serviceFailureError(err, connect.CodeInternal, "internal_error", []serviceErrorCase{ + {target: codespace_service.ErrRegistrationUnauthenticated, code: connect.CodeUnauthenticated, category: "unauthenticated"}, + {target: codespace_service.ErrRegistrationStateUnavailable, code: connect.CodeFailedPrecondition, category: "state_unavailable"}, + }) + } + return connect.NewResponse(&codespacev1.RegisterManagerResponse{ + ManagerId: manager.ID, + ManagerSecret: secret, + }), nil +} + +// DeclareManager stores the authenticated Manager's current declaration. +func (s *Service) DeclareManager( + ctx context.Context, + req *connect.Request[codespacev1.DeclareManagerRequest], +) (*connect.Response[codespacev1.DeclareManagerResponse], error) { + manager := GetManager(ctx) + if err := codespace_service.DeclareManager(ctx, manager, codespace_service.DeclareManagerOptions{ + GatewayURL: req.Msg.GetGatewayUrl(), + GatewaySSHAddr: req.Msg.GetGatewaySshAddr(), + Environments: req.Msg.GetEnvironments(), + Version: req.Msg.GetVersion(), + Name: req.Msg.GetName(), + RuntimeState: req.Msg.GetManagerRuntimeState(), + GatewaySSHHostKeyAlgorithm: req.Msg.GetGatewaySshHostKeyAlgorithm(), + GatewaySSHHostKeyFingerprintSHA256: req.Msg.GetGatewaySshHostKeyFingerprintSha256(), + GatewaySSHHostKeyUpdatedUnix: req.Msg.GetGatewaySshHostKeyUpdatedUnix(), + }); err != nil { + return nil, serviceFailureError(err, connect.CodeInvalidArgument, "invalid_declaration", []serviceErrorCase{ + {target: codespace_service.ErrDeclareGatewayURLConflict, code: connect.CodeFailedPrecondition, category: "gateway_url_conflict"}, + {target: codespace_service.ErrDeclareGatewaySSHAddrConflict, code: connect.CodeFailedPrecondition, category: "gateway_ssh_addr_conflict"}, + }) + } + heartbeatMillis, metadataRefreshMillis, maxMessageBytes, giteaWebURL := codespace_service.ManagerServiceTimings() + return connect.NewResponse(&codespacev1.DeclareManagerResponse{ + HeartbeatIntervalMilliseconds: heartbeatMillis, + RuntimeMetadataRefreshIntervalMilliseconds: metadataRefreshMillis, + ControlPlaneMaxMessageSizeBytes: maxMessageBytes, + GiteaWebUrl: giteaWebURL, + }), nil +} + +// FetchOperations returns operation payloads and renewed leases for the authenticated Manager. +func (s *Service) FetchOperations( + ctx context.Context, + req *connect.Request[codespacev1.FetchOperationsRequest], +) (*connect.Response[codespacev1.FetchOperationsResponse], error) { + manager := GetManager(ctx) + result, err := codespace_service.FetchOperations(ctx, manager, codespace_service.FetchOperationsOptions{ + StartupCapacityAvailable: req.Msg.GetStartupCapacityAvailable(), + AcceptedOperationTypes: req.Msg.GetAcceptedOperationTypes(), + AcceptedCreateTags: req.Msg.GetAcceptedCreateTags(), + ObservedOperations: req.Msg.GetObservedOperations(), + CleanupCapacityAvailable: req.Msg.GetCleanupCapacityAvailable(), + }) + if err != nil { + return nil, serviceFailureError(err, connect.CodeInvalidArgument, "invalid_argument", []serviceErrorCase{ + {target: codespace_service.ErrFetchStateHistoryConflict, code: connect.CodeFailedPrecondition, category: "state_history_conflict"}, + {target: codespace_service.ErrFetchManagerUnavailable, code: connect.CodeUnavailable, category: "manager_offline"}, + }) + } + return connect.NewResponse(result), nil +} + +// ReportInstances accepts a complete Runtime inventory snapshot. +func (s *Service) ReportInstances( + ctx context.Context, + req *connect.Request[codespacev1.ReportInstancesRequest], +) (*connect.Response[codespacev1.ReportInstancesResponse], error) { + manager := GetManager(ctx) + result, err := codespace_service.ReportInstances(ctx, manager, codespace_service.ReportInstancesOptions{ + InventoryGeneration: req.Msg.GetInventoryGeneration(), + Instances: req.Msg.GetInstances(), + }) + if err != nil { + var staleGeneration *codespace_service.StaleGenerationError + switch { + case errors.As(err, &staleGeneration): + return nil, failureErrorWithStaleGeneration(connect.CodeFailedPrecondition, "stale_generation", staleGeneration.CurrentGeneration, err) + case errors.Is(err, codespace_service.ErrReportInstancesStateHistoryConflict): + return nil, failureError(connect.CodeFailedPrecondition, "state_history_conflict", err) + case errors.Is(err, codespace_service.ErrReportInstancesManagerUnavailable): + return nil, failureError(connect.CodeUnavailable, "manager_offline", err) + default: + return nil, failureError(connect.CodeInvalidArgument, "invalid_argument", err) + } + } + return connect.NewResponse(result), nil +} + +// FinalizeOperation reports the authenticated Manager's final operation result. +func (s *Service) FinalizeOperation( + ctx context.Context, + req *connect.Request[codespacev1.FinalizeOperationRequest], +) (*connect.Response[codespacev1.FinalizeOperationResponse], error) { + manager := GetManager(ctx) + response, err := codespace_service.FinalizeOperation(ctx, manager, codespace_service.FinalizeOperationOptions{ + CodespaceUUID: req.Msg.GetCodespaceUuid(), + OperationRVersion: req.Msg.GetOperationRversion(), + OperationType: req.Msg.GetOperationType(), + FinalStatus: req.Msg.GetStatus(), + }) + if err != nil { + return nil, serviceFailureError(err, connect.CodeInvalidArgument, "invalid_argument", []serviceErrorCase{ + {target: codespace_service.ErrFinalizeGiteaTokenRequired, code: connect.CodeFailedPrecondition, category: "gitea_token_required"}, + {target: codespace_service.ErrFinalizeMetadataRequired, code: connect.CodeFailedPrecondition, category: "metadata_required"}, + }) + } + return connect.NewResponse(response), nil +} + +// UpdateLog appends sanitized log lines for the authenticated Manager's active operation. +func (s *Service) UpdateLog( + ctx context.Context, + req *connect.Request[codespacev1.UpdateLogRequest], +) (*connect.Response[codespacev1.UpdateLogResponse], error) { + manager := GetManager(ctx) + result, err := codespace_service.UpdateLog(ctx, manager, codespace_service.UpdateLogOptions{ + CodespaceUUID: req.Msg.GetCodespaceUuid(), + OperationRVersion: req.Msg.GetOperationRversion(), + Offset: req.Msg.GetOffset(), + Lines: req.Msg.GetLines(), + }) + if err != nil { + var offsetErr *codespace_service.LogOffsetError + switch { + case errors.As(err, &offsetErr) && errors.Is(err, codespace_service.ErrUpdateLogOffsetConflict): + return nil, failureErrorWithLogOffset(connect.CodeAborted, "offset_conflict", offsetErr.CurrentOffset, err) + case errors.As(err, &offsetErr) && errors.Is(err, codespace_service.ErrUpdateLogOffsetGap): + return nil, failureErrorWithLogOffset(connect.CodeAborted, "offset_gap", offsetErr.CurrentOffset, err) + case errors.Is(err, codespace_service.ErrUpdateLogNotFound): + return nil, failureError(connect.CodeNotFound, "codespace_not_found", err) + case errors.Is(err, codespace_service.ErrUpdateLogStaleOperation): + return nil, failureError(connect.CodeFailedPrecondition, "stale_operation", err) + case errors.Is(err, codespace_service.ErrUpdateLogSizeExceeded): + return nil, failureError(connect.CodeResourceExhausted, "log_size_exceeded", err) + default: + return nil, failureError(connect.CodeInvalidArgument, "invalid_argument", err) + } + } + return connect.NewResponse(result), nil +} + +// ReportRuntimeMetadata stores the authenticated Manager's current Runtime Metadata snapshot. +func (s *Service) ReportRuntimeMetadata( + ctx context.Context, + req *connect.Request[codespacev1.ReportRuntimeMetadataRequest], +) (*connect.Response[codespacev1.ReportRuntimeMetadataResponse], error) { + manager := GetManager(ctx) + err := codespace_service.ReportRuntimeMetadata(ctx, manager, codespace_service.ReportRuntimeMetadataOptions{ + CodespaceUUID: req.Msg.GetCodespaceUuid(), + Metadata: req.Msg.GetMetadata(), + MetadataGeneration: req.Msg.GetMetadataGeneration(), + }) + if err != nil { + return nil, reportRuntimeMetadataError(err) + } + return connect.NewResponse(&codespacev1.ReportRuntimeMetadataResponse{}), nil +} + +// ReportRuntimeTransition stores the authenticated Manager's local stopped or failed fact. +func (s *Service) ReportRuntimeTransition( + ctx context.Context, + req *connect.Request[codespacev1.ReportRuntimeTransitionRequest], +) (*connect.Response[codespacev1.ReportRuntimeTransitionResponse], error) { + manager := GetManager(ctx) + err := codespace_service.ReportRuntimeTransition(ctx, manager, codespace_service.ReportRuntimeTransitionOptions{ + CodespaceUUID: req.Msg.GetCodespaceUuid(), + RuntimeGeneration: req.Msg.GetRuntimeGeneration(), + ObservedOperationRVersion: req.Msg.GetObservedOperationRversion(), + RuntimeState: req.Msg.GetRuntimeState(), + }) + if err != nil { + return nil, reportRuntimeTransitionError(err) + } + return connect.NewResponse(&codespacev1.ReportRuntimeTransitionResponse{}), nil +} + +func reportRuntimeMetadataError(err error) error { + return reportRuntimeError(err, []serviceErrorCase{ + {target: codespace_service.ErrRuntimeMetadataGenerationConflict, code: connect.CodeFailedPrecondition, category: "generation_conflict"}, + {target: codespace_service.ErrRuntimeMetadataVersionExhausted, code: connect.CodeFailedPrecondition, category: "version_exhausted"}, + {target: codespace_service.ErrRuntimeMetadataManagerMismatch, code: connect.CodeFailedPrecondition, category: "manager_mismatch"}, + {target: codespace_service.ErrRuntimeMetadataStaleOperation, code: connect.CodeFailedPrecondition, category: "stale_operation"}, + {target: codespace_service.ErrRuntimeMetadataManagerOffline, code: connect.CodeUnavailable, category: "manager_offline"}, + {target: codespace_service.ErrRuntimeMetadataStateUnavailable, code: connect.CodeFailedPrecondition, category: "state_unavailable"}, + }) +} + +func reportRuntimeTransitionError(err error) error { + return reportRuntimeError(err, []serviceErrorCase{ + {target: codespace_service.ErrRuntimeTransitionNotFound, code: connect.CodeNotFound, category: "codespace_not_found"}, + {target: codespace_service.ErrRuntimeTransitionManagerMismatch, code: connect.CodeFailedPrecondition, category: "manager_mismatch"}, + {target: codespace_service.ErrRuntimeTransitionCurrentOperationConflict, code: connect.CodeAborted, category: "current_operation_conflict"}, + {target: codespace_service.ErrRuntimeTransitionManagerOffline, code: connect.CodeUnavailable, category: "manager_offline"}, + {target: codespace_service.ErrRuntimeTransitionStaleOperation, code: connect.CodeFailedPrecondition, category: "stale_operation"}, + {target: codespace_service.ErrRuntimeTransitionGenerationConflict, code: connect.CodeFailedPrecondition, category: "generation_conflict"}, + }) +} + +type serviceErrorCase struct { + target error + code connect.Code + category string +} + +func reportRuntimeError(err error, cases []serviceErrorCase) error { + var staleGeneration *codespace_service.StaleGenerationError + if errors.As(err, &staleGeneration) { + return failureErrorWithStaleGeneration(connect.CodeFailedPrecondition, "stale_generation", staleGeneration.CurrentGeneration, err) + } + return serviceFailureError(err, connect.CodeInvalidArgument, "invalid_argument", cases) +} + +func serviceFailureError(err error, fallbackCode connect.Code, fallbackCategory string, cases []serviceErrorCase) error { + for _, errCase := range cases { + if errors.Is(err, errCase.target) { + return failureError(errCase.code, errCase.category, err) + } + } + return failureError(fallbackCode, fallbackCategory, err) +} + +// RequestRuntimeAccess returns the authenticated Manager's current runtime access material. +func (s *Service) RequestRuntimeAccess( + ctx context.Context, + req *connect.Request[codespacev1.RequestRuntimeAccessRequest], +) (*connect.Response[codespacev1.RequestRuntimeAccessResponse], error) { + manager := GetManager(ctx) + result, err := codespace_service.RequestRuntimeAccess(ctx, manager, codespace_service.RequestRuntimeAccessOptions{ + CodespaceUUID: req.Msg.GetCodespaceUuid(), + OperationRVersion: req.Msg.GetOperationRversion(), + GitSSHPublicKey: req.Msg.GetGitSshPublicKey(), + }) + if err != nil { + return nil, serviceFailureError(err, connect.CodeInvalidArgument, "invalid_argument", []serviceErrorCase{ + {target: codespace_service.ErrRequestRuntimeAccessNotFound, code: connect.CodeNotFound, category: "codespace_not_found"}, + {target: codespace_service.ErrRequestRuntimeAccessManagerMismatch, code: connect.CodeFailedPrecondition, category: "manager_mismatch"}, + {target: codespace_service.ErrRequestRuntimeAccessStateUnavailable, code: connect.CodeFailedPrecondition, category: "state_unavailable"}, + {target: codespace_service.ErrRequestRuntimeAccessManagerOffline, code: connect.CodeUnavailable, category: "manager_offline"}, + {target: codespace_service.ErrRequestRuntimeAccessUserNotFound, code: connect.CodeFailedPrecondition, category: "user_not_found"}, + {target: codespace_service.ErrRuntimeGitSSHKeyLoginRestricted, code: connect.CodeFailedPrecondition, category: "login_restricted"}, + {target: codespace_service.ErrRuntimeGitSSHKeyInvalidPublicKey, code: connect.CodeInvalidArgument, category: "invalid_public_key"}, + {target: codespace_service.ErrRuntimeGitSSHKeyConflict, code: connect.CodeFailedPrecondition, category: "key_conflict"}, + {target: codespace_service.ErrRuntimeGitSSHKeyIntegrity, code: connect.CodeInternal, category: "internal_error"}, + }) + } + secrets := make([]*codespacev1.RuntimeSecretEnvironmentVariable, 0, len(result.Secrets)) + for _, secret := range result.Secrets { + secrets = append(secrets, &codespacev1.RuntimeSecretEnvironmentVariable{Name: secret.Name, Value: secret.Value}) + } + return connect.NewResponse(&codespacev1.RequestRuntimeAccessResponse{ + Token: result.Token, + ServerUrl: result.ServerURL, + Secrets: secrets, + GitSshKnownHostsLines: result.GitSSHKnownHosts, + }), nil +} + +// RequestIdleStop authorizes an idle-triggered stop against current Gitea state. +func (s *Service) RequestIdleStop( + ctx context.Context, + req *connect.Request[codespacev1.RequestIdleStopRequest], +) (*connect.Response[codespacev1.RequestIdleStopResponse], error) { + manager := GetManager(ctx) + settings := req.Msg.GetObservedSettings() + result, err := codespace_service.RequestIdleStop(ctx, manager, codespace_service.RequestIdleStopOptions{ + CodespaceUUID: req.Msg.GetCodespaceUuid(), + ObservedAutoStopEnabled: settings.GetAutoStopEnabled(), + ObservedIdleTimeoutSeconds: settings.GetIdleTimeoutSeconds(), + ObservedInteractionGeneration: settings.GetInteractionGeneration(), + }) + if err != nil { + return nil, serviceFailureError(err, connect.CodeInvalidArgument, "invalid_argument", []serviceErrorCase{ + {target: codespace_service.ErrRequestIdleStopNotFound, code: connect.CodeNotFound, category: "codespace_not_found"}, + {target: codespace_service.ErrRequestIdleStopManagerMismatch, code: connect.CodeFailedPrecondition, category: "manager_mismatch"}, + {target: codespace_service.ErrRequestIdleStopManagerUnavailable, code: connect.CodeUnavailable, category: "manager_offline"}, + {target: codespace_service.ErrRequestIdleStopVersionExhausted, code: connect.CodeFailedPrecondition, category: "version_exhausted"}, + }) + } + return connect.NewResponse(result), nil +} + +// ValidatePublicEndpoint authorizes unauthenticated access to one public Endpoint. +func (s *Service) ValidatePublicEndpoint( + ctx context.Context, + req *connect.Request[codespacev1.ValidatePublicEndpointRequest], +) (*connect.Response[codespacev1.ValidatePublicEndpointResponse], error) { + manager := GetManager(ctx) + result, err := codespace_service.ValidatePublicEndpoint(ctx, manager, codespace_service.ValidatePublicEndpointOptions{ + CodespaceUUID: req.Msg.GetCodespaceUuid(), + EndpointID: req.Msg.GetEndpointId(), + }) + if err != nil { + return nil, failureError(connect.CodeInvalidArgument, "invalid_argument", err) + } + return connect.NewResponse(result), nil +} + +// ValidateOpenToken authorizes one Gateway Open Token exchange. +func (s *Service) ValidateOpenToken( + ctx context.Context, + req *connect.Request[codespacev1.ValidateOpenTokenRequest], +) (*connect.Response[codespacev1.ValidateOpenTokenResponse], error) { + manager := GetManager(ctx) + result, err := codespace_service.ValidateOpenToken(ctx, manager, codespace_service.ValidateOpenTokenOptions{ + Code: req.Msg.GetCode(), + }) + if err != nil { + return nil, failureError(connect.CodeInternal, "internal_error", err) + } + return connect.NewResponse(result), nil +} + +// VerifySSHPublicKey authorizes one new Gateway SSH transport. +func (s *Service) VerifySSHPublicKey( + ctx context.Context, + req *connect.Request[codespacev1.VerifySSHPublicKeyRequest], +) (*connect.Response[codespacev1.VerifySSHPublicKeyResponse], error) { + manager := GetManager(ctx) + result, err := codespace_service.VerifySSHPublicKey(ctx, manager, codespace_service.VerifySSHPublicKeyOptions{ + CodespaceUUID: req.Msg.GetCodespaceUuid(), + PublicKey: req.Msg.GetPublicKey(), + }) + if err != nil { + return nil, failureError(connect.CodeInvalidArgument, "invalid_argument", err) + } + return connect.NewResponse(result), nil +} + +// RevalidateGatewaySession checks whether an existing Gateway session remains authorized. +func (s *Service) RevalidateGatewaySession( + ctx context.Context, + req *connect.Request[codespacev1.RevalidateGatewaySessionRequest], +) (*connect.Response[codespacev1.RevalidateGatewaySessionResponse], error) { + manager := GetManager(ctx) + result, err := codespace_service.RevalidateGatewaySession(ctx, manager, req.Msg) + if err != nil { + return nil, failureError(connect.CodeInvalidArgument, "invalid_argument", err) + } + return connect.NewResponse(result), nil +} diff --git a/routers/api/codespace/manager/manager_test.go b/routers/api/codespace/manager/manager_test.go new file mode 100644 index 0000000000000..fbadeb0f71de9 --- /dev/null +++ b/routers/api/codespace/manager/manager_test.go @@ -0,0 +1,545 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package manager + +import ( + "math" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + "gitea.dev/codespace-proto-go/codespace/v1/codespacev1connect" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestManagerServiceProtocolAuthenticationAndRegistration(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + client, cleanup := newManagerTestClient(t) + defer cleanup() + + _, err := client.RegisterManager(t.Context(), connect.NewRequest(&codespacev1.RegisterManagerRequest{ + ProtocolVersion: 0, + RegistrationToken: "missing", + })) + require.Error(t, err) + assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) + assert.Equal(t, "protocol_mismatch", failureCategory(t, err)) + + _, err = client.RegisterManager(t.Context(), connect.NewRequest(&codespacev1.RegisterManagerRequest{ + ProtocolVersion: 1, + RegistrationToken: "missing", + })) + require.Error(t, err) + assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) + assert.Equal(t, "unauthenticated", failureCategory(t, err)) + + require.NoError(t, db.Insert(t.Context(), &codespace_model.ManagerToken{ + Token: "registration-token", + UserID: 0, + })) + registered, err := client.RegisterManager(t.Context(), connect.NewRequest(&codespacev1.RegisterManagerRequest{ + ProtocolVersion: 1, + RegistrationToken: "registration-token", + })) + require.NoError(t, err) + require.Positive(t, registered.Msg.GetManagerId()) + require.Len(t, registered.Msg.GetManagerSecret(), 64) + + declaration := &codespacev1.DeclareManagerRequest{ + ProtocolVersion: 1, + GatewayUrl: "https://WorkSpace.EXAMPLE.com:443/", + GatewaySshAddr: "WorkSpace.EXAMPLE.com:0022", + Environments: []*codespacev1.EnvironmentTag{{Tag: "Default", Description: "Default environment"}, {Tag: "incus"}}, + Version: " 0.1.0 ", + Name: " manager-one ", + ManagerRuntimeState: codespacev1.ManagerRuntimeState_MANAGER_RUNTIME_STATE_ONLINE, + GatewaySshHostKeyAlgorithm: " ssh-ed25519 ", + GatewaySshHostKeyFingerprintSha256: " SHA256:test ", + GatewaySshHostKeyUpdatedUnix: 1, + } + _, err = client.DeclareManager(t.Context(), managerRequest(registered.Msg.GetManagerId(), "bad-secret", declaration)) + require.Error(t, err) + assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) + assert.Equal(t, "unauthenticated", failureCategory(t, err)) + + _, err = client.DeclareManager(t.Context(), managerRequest(registered.Msg.GetManagerId()+1000, registered.Msg.GetManagerSecret(), declaration)) + require.Error(t, err) + assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) + assert.Equal(t, "manager_unregistered", failureCategory(t, err)) + + declaration.ProtocolVersion = 0 + _, err = client.DeclareManager(t.Context(), managerRequest(registered.Msg.GetManagerId(), registered.Msg.GetManagerSecret(), declaration)) + require.Error(t, err) + assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) + assert.Equal(t, "protocol_mismatch", failureCategory(t, err)) + + declaration.ProtocolVersion = 1 + declared, err := client.DeclareManager(t.Context(), managerRequest(registered.Msg.GetManagerId(), registered.Msg.GetManagerSecret(), declaration)) + require.NoError(t, err) + assert.Positive(t, declared.Msg.GetHeartbeatIntervalMilliseconds()) + assert.Positive(t, declared.Msg.GetRuntimeMetadataRefreshIntervalMilliseconds()) + assert.Positive(t, declared.Msg.GetControlPlaneMaxMessageSizeBytes()) + assert.NotEmpty(t, declared.Msg.GetGiteaWebUrl()) + + manager := new(codespace_model.Manager) + has, err := db.GetEngine(t.Context()).ID(registered.Msg.GetManagerId()).Get(manager) + require.NoError(t, err) + require.True(t, has) + assert.Equal(t, "manager-one", manager.Name) + assert.JSONEq(t, `[{"tag":"default","description":"Default environment"},{"tag":"incus"}]`, manager.TagsJSON) + assert.Equal(t, "0.1.0", manager.Version) + assert.Equal(t, "ssh-ed25519", manager.GatewaySSHHostKeyAlgorithm) + assert.Equal(t, "SHA256:test", manager.GatewaySSHHostKeyFingerprintSHA256) + assert.EqualValues(t, 1, manager.GatewaySSHHostKeyUpdatedUnix) + + count, err := db.GetEngine(t.Context()).Where("manager_id = ?", manager.ID).Count(new(codespace_model.ManagerAddress)) + require.NoError(t, err) + assert.EqualValues(t, 2, count) + addresses := make([]*codespace_model.ManagerAddress, 0, 2) + require.NoError(t, db.GetEngine(t.Context()).Where("manager_id = ?", manager.ID).Find(&addresses)) + addressByKind := map[string]string{} + for _, address := range addresses { + addressByKind[address.Kind] = address.Address + } + assert.Equal(t, "https://workspace.example.com", addressByKind[codespace_model.ManagerAddressGateway]) + assert.Equal(t, "workspace.example.com:22", addressByKind[codespace_model.ManagerAddressSSH]) +} + +func TestManagerServiceRequestProtocolVersionFieldNumbers(t *testing.T) { + requests := []proto.Message{ + &codespacev1.RegisterManagerRequest{}, + &codespacev1.DeclareManagerRequest{}, + &codespacev1.FetchOperationsRequest{}, + &codespacev1.ReportInstancesRequest{}, + &codespacev1.FinalizeOperationRequest{}, + &codespacev1.UpdateLogRequest{}, + &codespacev1.ReportRuntimeMetadataRequest{}, + &codespacev1.ReportRuntimeTransitionRequest{}, + &codespacev1.RequestRuntimeAccessRequest{}, + &codespacev1.RequestIdleStopRequest{}, + &codespacev1.ValidatePublicEndpointRequest{}, + &codespacev1.ValidateOpenTokenRequest{}, + &codespacev1.VerifySSHPublicKeyRequest{}, + &codespacev1.RevalidateGatewaySessionRequest{}, + } + for _, request := range requests { + t.Run(string(request.ProtoReflect().Descriptor().FullName()), func(t *testing.T) { + fields := request.ProtoReflect().Descriptor().Fields() + protocolField := fields.ByName("protocol_version") + require.NotNil(t, protocolField) + assert.EqualValues(t, 1, protocolField.Number()) + }) + } +} + +func TestManagerServiceDeclareAddressConflicts(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + client, cleanup := newManagerTestClient(t) + defer cleanup() + + firstManager, firstSecret := insertManagerTestIdentity(t, 0) + secondManager, secondSecret := insertManagerTestIdentity(t, 0) + + firstDeclaration := managerTestDeclaration("https://workspace.example.com", "workspace.example.com:22") + _, err := client.DeclareManager(t.Context(), managerRequest(firstManager.ID, firstSecret, firstDeclaration)) + require.NoError(t, err) + + _, err = client.DeclareManager(t.Context(), managerRequest(secondManager.ID, secondSecret, + managerTestDeclaration("https://workspace.example.com", "other-ssh.example.com:22"))) + require.Error(t, err) + assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) + assert.Equal(t, "gateway_url_conflict", failureCategory(t, err)) + + _, err = client.DeclareManager(t.Context(), managerRequest(secondManager.ID, secondSecret, + managerTestDeclaration("https://other-gateway.example.com", "workspace.example.com:22"))) + require.Error(t, err) + assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) + assert.Equal(t, "gateway_ssh_addr_conflict", failureCategory(t, err)) + + count, err := db.GetEngine(t.Context()).Where("manager_id = ?", secondManager.ID).Count(new(codespace_model.ManagerAddress)) + require.NoError(t, err) + assert.EqualValues(t, 0, count) +} + +func TestManagerServiceDeclareAcceptsCookieScopeWarning(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + client, cleanup := newManagerTestClient(t) + defer cleanup() + t.Cleanup(test.MockVariableValue(&setting.AppURL, "https://gitea.example.com/")) + + manager, secret := insertManagerTestIdentity(t, 0) + + _, err := client.DeclareManager(t.Context(), managerRequest(manager.ID, secret, + managerTestDeclaration("https://workspace.example.com", "workspace.example.com:22"))) + require.NoError(t, err) + + count, err := db.GetEngine(t.Context()).Where("manager_id = ?", manager.ID).Count(new(codespace_model.ManagerAddress)) + require.NoError(t, err) + assert.EqualValues(t, 2, count) +} + +func TestManagerServiceFetchPayloadAndLease(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + t.Cleanup(test.MockVariableValue(&setting.Repository.DisableHTTPGit, false)) + t.Cleanup(test.MockVariableValue(&setting.SSH.Disabled, false)) + t.Cleanup(test.MockVariableValue(&setting.SSH.Domain, "localhost")) + t.Cleanup(test.MockVariableValue(&setting.SSH.Port, 22)) + t.Cleanup(test.MockVariableValue(&setting.SSH.StartBuiltinServer, false)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GitSSHKnownHosts, []string{ + "localhost ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf", + })) + manager, secret := insertManagerTestIdentity(t, 0) + client, cleanup := newManagerTestClient(t) + defer cleanup() + + codespaceUUID := "11111111-2222-4111-8111-111111111111" + insertManagerTestCodespace(t, 0, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 41, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + InteractionGeneration: 7, + }) + + fetched, err := client.FetchOperations(t.Context(), managerRequest(manager.ID, secret, &codespacev1.FetchOperationsRequest{ + ProtocolVersion: 1, + StartupCapacityAvailable: 1, + AcceptedOperationTypes: []codespacev1.AcceptedOperationType{ + codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_CREATE, + }, + AcceptedCreateTags: []string{"default"}, + })) + require.NoError(t, err) + require.Len(t, fetched.Msg.GetOperations(), 1) + operation := fetched.Msg.GetOperations()[0] + assert.Equal(t, codespaceUUID, operation.GetCodespaceUuid()) + assert.EqualValues(t, 41, operation.GetOperationRversion()) + assert.Positive(t, operation.GetLeaseValidForMilliseconds()) + require.NotNil(t, operation.GetCreate()) + assert.NotEmpty(t, operation.GetCreate().GetRepoCloneHttpUrl()) + assert.NotEmpty(t, operation.GetCreate().GetRepoCloneSshUrl()) + assert.Equal(t, codespacev1.GitProtocol_GIT_PROTOCOL_HTTP, operation.GetCreate().GetGitProtocol()) + assert.EqualValues(t, 7, operation.GetCreate().GetRuntimeSettings().GetInteractionGeneration()) + + renewed, err := client.FetchOperations(t.Context(), managerRequest(manager.ID, secret, &codespacev1.FetchOperationsRequest{ + ProtocolVersion: 1, + ObservedOperations: []*codespacev1.ObservedOperation{{ + CodespaceUuid: codespaceUUID, + OperationRversion: 41, + }}, + })) + require.NoError(t, err) + assert.Empty(t, renewed.Msg.GetOperations()) + require.Len(t, renewed.Msg.GetRenewedLeases(), 1) + assert.Equal(t, codespaceUUID, renewed.Msg.GetRenewedLeases()[0].GetCodespaceUuid()) +} + +func TestManagerServiceStructuredErrorDetails(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + manager, secret := insertManagerTestIdentity(t, 3) + client, cleanup := newManagerTestClient(t) + defer cleanup() + + _, err := client.ReportInstances(t.Context(), managerRequest(manager.ID, secret, &codespacev1.ReportInstancesRequest{ + ProtocolVersion: 1, + InventoryGeneration: 2, + })) + require.Error(t, err) + assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) + assert.Equal(t, "stale_generation", failureCategory(t, err)) + assert.EqualValues(t, 3, staleGenerationCurrent(t, err)) + + codespaceUUID := "90909090-9090-4909-8909-909090909090" + insertManagerTestCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 25, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + written, err := client.UpdateLog(t.Context(), managerRequest(manager.ID, secret, &codespacev1.UpdateLogRequest{ + ProtocolVersion: 1, + CodespaceUuid: codespaceUUID, + OperationRversion: 25, + Lines: []*codespacev1.LogLine{{ + TimestampUnixNano: time.Now().UnixNano(), + Message: "first", + }}, + })) + require.NoError(t, err) + require.Positive(t, written.Msg.GetNextOffset()) + + _, err = client.UpdateLog(t.Context(), managerRequest(manager.ID, secret, &codespacev1.UpdateLogRequest{ + ProtocolVersion: 1, + CodespaceUuid: codespaceUUID, + OperationRversion: 25, + Offset: written.Msg.GetNextOffset() + 1, + Lines: []*codespacev1.LogLine{{ + TimestampUnixNano: time.Now().UnixNano(), + Message: "gap", + }}, + })) + require.Error(t, err) + assert.Equal(t, connect.CodeAborted, connect.CodeOf(err)) + assert.Equal(t, "offset_gap", failureCategory(t, err)) + assert.Equal(t, written.Msg.GetNextOffset(), logOffsetCurrent(t, err)) +} + +func TestManagerServiceManagerOfflineCategory(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + manager, secret := insertManagerTestIdentity(t, 0) + manager.RuntimeState = codespace_model.ManagerRuntimeStateRecovering + _, err := db.GetEngine(t.Context()).ID(manager.ID).Cols("runtime_state").Update(manager) + require.NoError(t, err) + client, cleanup := newManagerTestClient(t) + defer cleanup() + + _, err = client.FetchOperations(t.Context(), managerRequest(manager.ID, secret, &codespacev1.FetchOperationsRequest{ + ProtocolVersion: 1, + })) + require.Error(t, err) + assert.Equal(t, connect.CodeUnavailable, connect.CodeOf(err)) + assert.Equal(t, "manager_offline", failureCategory(t, err)) + + _, err = client.RequestIdleStop(t.Context(), managerRequest(manager.ID, secret, &codespacev1.RequestIdleStopRequest{ + ProtocolVersion: 1, + CodespaceUuid: "93939393-9393-4939-8939-939393939393", + ObservedSettings: &codespacev1.EffectiveCodespaceRuntimeSettings{ + AutoStopEnabled: true, + IdleTimeoutSeconds: 1800, + }, + })) + require.Error(t, err) + assert.Equal(t, connect.CodeUnavailable, connect.CodeOf(err)) + assert.Equal(t, "manager_offline", failureCategory(t, err)) + + offlineManager, offlineSecret := insertManagerTestIdentity(t, 0) + offlineManager.RuntimeState = "" + _, err = db.GetEngine(t.Context()).ID(offlineManager.ID).Cols("runtime_state").Update(offlineManager) + require.NoError(t, err) + + _, err = client.ReportRuntimeMetadata(t.Context(), managerRequest(offlineManager.ID, offlineSecret, &codespacev1.ReportRuntimeMetadataRequest{ + ProtocolVersion: 1, + CodespaceUuid: "93939393-9393-4939-8939-939393939393", + MetadataGeneration: 1, + Metadata: managerTestRuntimeMetadata(1), + })) + require.Error(t, err) + assert.Equal(t, connect.CodeUnavailable, connect.CodeOf(err)) + assert.Equal(t, "manager_offline", failureCategory(t, err)) + + _, err = client.RequestRuntimeAccess(t.Context(), managerRequest(offlineManager.ID, offlineSecret, &codespacev1.RequestRuntimeAccessRequest{ + ProtocolVersion: 1, + CodespaceUuid: "93939393-9393-4939-8939-939393939393", + OperationRversion: 1, + GitSshPublicKey: []byte("not-a-key"), + })) + require.Error(t, err) + assert.Equal(t, connect.CodeUnavailable, connect.CodeOf(err)) + assert.Equal(t, "manager_offline", failureCategory(t, err)) +} + +func TestManagerServiceReportRuntimeMetadataVersionExhausted(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + manager, secret := insertManagerTestIdentity(t, 0) + client, cleanup := newManagerTestClient(t) + defer cleanup() + + codespaceUUID := "94949494-9494-4949-8949-949494949494" + insertManagerTestCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 31, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + + _, err := client.ReportRuntimeMetadata(t.Context(), managerRequest(manager.ID, secret, &codespacev1.ReportRuntimeMetadataRequest{ + ProtocolVersion: 1, + CodespaceUuid: codespaceUUID, + MetadataGeneration: math.MaxInt64, + Metadata: managerTestRuntimeMetadata(31), + })) + require.NoError(t, err) + + _, err = client.ReportRuntimeMetadata(t.Context(), managerRequest(manager.ID, secret, &codespacev1.ReportRuntimeMetadataRequest{ + ProtocolVersion: 1, + CodespaceUuid: codespaceUUID, + MetadataGeneration: math.MaxInt64 - 1, + Metadata: managerTestRuntimeMetadata(31), + })) + require.Error(t, err) + assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) + assert.Equal(t, "version_exhausted", failureCategory(t, err)) +} + +func TestManagerServiceReportRuntimeMetadataDisabled(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + t.Cleanup(test.MockVariableValue(&setting.Codespace.Enabled, false)) + + manager, secret := insertManagerTestIdentity(t, 0) + client, cleanup := newManagerTestClient(t) + defer cleanup() + + _, err := client.ReportRuntimeMetadata(t.Context(), managerRequest(manager.ID, secret, &codespacev1.ReportRuntimeMetadataRequest{ + ProtocolVersion: 1, + CodespaceUuid: "93939393-9393-4939-8939-939393939393", + MetadataGeneration: 1, + Metadata: managerTestRuntimeMetadata(1), + })) + require.Error(t, err) + assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) + assert.Equal(t, "state_unavailable", failureCategory(t, err)) +} + +func newManagerTestClient(t *testing.T) (codespacev1connect.ManagerServiceClient, func()) { + t.Helper() + t.Cleanup(test.MockVariableValue(&setting.AppURL, "http://127.0.0.1:3000/")) + t.Cleanup(test.MockVariableValue(&setting.SessionConfig.Domain, "")) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GatewayRequireHTTPS, false)) + path, handler := NewManagerServiceHandler() + mux := http.NewServeMux() + mux.Handle(path, handler) + server := httptest.NewServer(mux) + return codespacev1connect.NewManagerServiceClient(server.Client(), server.URL), server.Close +} + +func managerRequest[T any](managerID int64, managerSecret string, message *T) *connect.Request[T] { + request := connect.NewRequest(message) + request.Header().Set(managerIDHeader, strconv.FormatInt(managerID, 10)) + request.Header().Set(managerSecretHeader, managerSecret) + return request +} + +func managerTestDeclaration(gatewayURL, gatewaySSHAddr string) *codespacev1.DeclareManagerRequest { + return &codespacev1.DeclareManagerRequest{ + ProtocolVersion: 1, + GatewayUrl: gatewayURL, + GatewaySshAddr: gatewaySSHAddr, + Environments: []*codespacev1.EnvironmentTag{{Tag: "default"}}, + Version: "0.1.0", + Name: "manager", + ManagerRuntimeState: codespacev1.ManagerRuntimeState_MANAGER_RUNTIME_STATE_ONLINE, + GatewaySshHostKeyAlgorithm: "ssh-ed25519", + GatewaySshHostKeyFingerprintSha256: "SHA256:test", + GatewaySshHostKeyUpdatedUnix: 1, + } +} + +func insertManagerTestIdentity(t *testing.T, inventoryGeneration int64) (*codespace_model.Manager, string) { + t.Helper() + manager := &codespace_model.Manager{ + Name: "manager", + RuntimeState: codespace_model.ManagerRuntimeStateOnline, + TagsJSON: `[{"tag":"default"}]`, + LastOnlineUnix: time.Now().Unix(), + InventoryGeneration: inventoryGeneration, + CreatedUnix: time.Now().Unix(), + } + secret := manager.GenerateManagerSecret() + require.NoError(t, db.Insert(t.Context(), manager)) + return manager, secret +} + +func insertManagerTestCodespace(t *testing.T, managerID int64, codespace *codespace_model.Codespace) { + t.Helper() + codespace.UserID = 1 + codespace.RepoID = 2 + codespace.ManagerID = managerID + codespace.RefType = "branch" + codespace.RefName = "main" + codespace.EnvironmentTag = "default" + codespace.CommitSHA = "0123456789abcdef0123456789abcdef01234567" + codespace.DevContainerDefaultImage = "mcr.microsoft.com/devcontainers/base:ubuntu" + codespace.AutoStopMode = codespace_model.AutoStopModeDefault + codespace.CreatedUnix = 1 + codespace.UpdatedUnix = 1 + require.NoError(t, db.Insert(t.Context(), codespace)) +} + +func failureCategory(t *testing.T, err error) string { + t.Helper() + var connectErr *connect.Error + require.ErrorAs(t, err, &connectErr) + for _, detail := range connectErr.Details() { + value, detailErr := detail.Value() + require.NoError(t, detailErr) + if failure, ok := value.(*codespacev1.FailureDetail); ok { + return failure.GetCategory() + } + } + require.FailNow(t, "missing failure detail") + return "" +} + +func staleGenerationCurrent(t *testing.T, err error) int64 { + t.Helper() + var connectErr *connect.Error + require.ErrorAs(t, err, &connectErr) + for _, detail := range connectErr.Details() { + value, detailErr := detail.Value() + require.NoError(t, detailErr) + if stale, ok := value.(*codespacev1.StaleGenerationDetail); ok { + return stale.GetCurrentGeneration() + } + } + require.FailNow(t, "missing stale generation detail") + return 0 +} + +func logOffsetCurrent(t *testing.T, err error) int64 { + t.Helper() + var connectErr *connect.Error + require.ErrorAs(t, err, &connectErr) + for _, detail := range connectErr.Details() { + value, detailErr := detail.Value() + require.NoError(t, detailErr) + if offset, ok := value.(*codespacev1.LogOffsetDetail); ok { + return offset.GetCurrentOffset() + } + } + require.FailNow(t, "missing log offset detail") + return 0 +} + +func managerTestRuntimeMetadata(operationRVersion int64) *codespacev1.RuntimeMetadata { + return &codespacev1.RuntimeMetadata{ + Endpoints: []*codespacev1.RuntimeEndpoint{{EndpointId: "workspace", Label: "Workspace"}}, + Boot: &codespacev1.RuntimeBoot{ + OperationRversion: operationRVersion, + Stage: codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_READY, + StartedUnix: 100, + LastUpdateUnix: 101, + }, + ResourceUsage: &codespacev1.RuntimeResourceUsage{ + Cpu: &codespacev1.RuntimeCPUUsage{UsedMillicores: 125, LimitMillicores: 1000}, + Memory: &codespacev1.RuntimeMemoryUsage{UsedBytes: 256 * 1024 * 1024, LimitBytes: 1024 * 1024 * 1024}, + Disk: &codespacev1.RuntimeDiskUsage{UsedBytes: 512 * 1024 * 1024, LimitBytes: 10 * 1024 * 1024 * 1024}, + ObservedUnix: 101, + }, + } +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index fa8d29c28b33a..3ea87f49e6257 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -66,6 +66,7 @@ import ( "errors" "fmt" "net/http" + "slices" "strings" auth_model "gitea.dev/models/auth" @@ -76,10 +77,12 @@ import ( "gitea.dev/models/unit" user_model "gitea.dev/models/user" "gitea.dev/modules/log" + "gitea.dev/modules/reqctx" "gitea.dev/modules/setting" api "gitea.dev/modules/structs" "gitea.dev/modules/util" "gitea.dev/modules/web" + web_types "gitea.dev/modules/web/types" "gitea.dev/routers/api/v1/activitypub" "gitea.dev/routers/api/v1/admin" "gitea.dev/routers/api/v1/misc" @@ -103,6 +106,14 @@ import ( "github.com/go-chi/cors" ) +const ( + codespaceTokenRoutePolicyDataKey = "CodespaceTokenRoutePolicy" + codespaceTokenRepositoryRouteDataKey = "CodespaceTokenRepositoryRoute" + codespaceTokenRoutePolicySelf = "self" + codespaceTokenRoutePolicyPublicInfo = "public_info" + codespaceTokenRoutePolicySignedArtifact = "signed_artifact" +) + func sudo() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { sudo := ctx.FormString("sudo") @@ -111,6 +122,10 @@ func sudo() func(ctx *context.APIContext) { } if len(sudo) > 0 { + if _, ok := ctx.CodespaceTokenRepoID(); ok { + ctx.APIError(http.StatusForbidden, "codespace token cannot use sudo") + return + } if ctx.IsSigned && ctx.Doer.IsAdmin { user, err := user_model.GetUserByName(ctx, sudo) if err != nil { @@ -186,6 +201,7 @@ func repoAssignment() func(ctx *context.APIContext) { repo.Owner = owner ctx.Repo.Repository = repo + ctx.UseAnonymousForPublicCodespaceRead(repo) if taskID, ok := user_model.GetActionsUserTaskID(ctx.Doer); ok { ctx.Repo.Permission, err = access_model.GetActionsUserRepoPermission(ctx, repo, ctx.Doer, taskID) @@ -216,6 +232,10 @@ func repoAssignment() func(ctx *context.APIContext) { } if !ctx.TokenCanAccessRepo(repo) { + if _, ok := ctx.CodespaceTokenRepoID(); ok { + ctx.APIError(http.StatusForbidden, "codespace token does not grant access to this repository") + return + } ctx.APIErrorNotFound() return } @@ -318,6 +338,49 @@ func contextAuthenticatedUser() func(ctx *context.APIContext) { } } +func codespaceTokenRoute(policy string) web_types.PreMiddlewareProvider { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if store := reqctx.GetRequestDataStore(req.Context()); store != nil { + store.GetData()[codespaceTokenRoutePolicyDataKey] = policy + } + next.ServeHTTP(w, req) + }) + } +} + +var codespaceTokenRepositoryRoute web_types.PreMiddlewareProvider = func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if store := reqctx.GetRequestDataStore(req.Context()); store != nil { + store.GetData()[codespaceTokenRepositoryRouteDataKey] = true + } + next.ServeHTTP(w, req) + }) +} + +func codespaceTokenRouteGuard(ctx *context.APIContext) { + if _, ok := ctx.CodespaceTokenRepoID(); !ok { + return + } + if ctx.GetData()[codespaceTokenRepositoryRouteDataKey] == true { + return + } + policy, _ := ctx.GetData()[codespaceTokenRoutePolicyDataKey].(string) + switch policy { + case codespaceTokenRoutePolicySelf, + codespaceTokenRoutePolicyPublicInfo, + codespaceTokenRoutePolicySignedArtifact: + return + default: + ctx.APIError(http.StatusForbidden, "codespace token is not allowed for this API route") + } +} + +func codespaceTokenRoutePolicy(ctx *context.APIContext) string { + policy, _ := ctx.GetData()[codespaceTokenRoutePolicyDataKey].(string) + return policy +} + // if a token is being used for auth, we check that it contains the required scope // if a token is not being used, reqToken will enforce other sign in methods func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeCategory) func(ctx *context.APIContext) { @@ -448,6 +511,10 @@ func reqAdmin() func(ctx *context.APIContext) { // reqRepoWriter user should have a permission to write to a repo, or be a site admin func reqRepoWriter(unitTypes ...unit.Type) func(ctx *context.APIContext) { return func(ctx *context.APIContext) { + if !codespaceTokenAllowsRepositoryUnit(ctx, perm.AccessModeWrite, unitTypes...) { + ctx.APIError(http.StatusForbidden, "codespace token does not have the required repository permission") + return + } if !ctx.IsUserRepoWriter(unitTypes) && !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() { ctx.APIError(http.StatusForbidden, "user should have a permission to write to a repo") return @@ -458,6 +525,10 @@ func reqRepoWriter(unitTypes ...unit.Type) func(ctx *context.APIContext) { // reqRepoReader user should have specific read permission or be a repo admin or a site admin func reqRepoReader(unitType unit.Type) func(ctx *context.APIContext) { return func(ctx *context.APIContext) { + if !codespaceTokenAllowsRepositoryUnit(ctx, perm.AccessModeRead, unitType) { + ctx.APIError(http.StatusForbidden, "codespace token does not have the required repository permission") + return + } if !ctx.Repo.Permission.CanRead(unitType) && !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() { ctx.APIError(http.StatusForbidden, "user should have specific read permission or be a repo admin or a site admin") return @@ -465,6 +536,49 @@ func reqRepoReader(unitType unit.Type) func(ctx *context.APIContext) { } } +func codespaceTokenAllowsRepositoryUnit(ctx *context.APIContext, mode perm.AccessMode, unitTypes ...unit.Type) bool { + if _, ok := ctx.CodespaceTokenRepoID(); !ok { + return true + } + return slices.ContainsFunc(unitTypes, func(unitType unit.Type) bool { + return ctx.CodespaceTokenAllowsRepository(unitType, mode) + }) +} + +func requireCodespaceTokenRepositoryPermission(ctx *context.APIContext, mode perm.AccessMode, unitTypes ...unit.Type) { + if _, ok := ctx.CodespaceTokenRepoID(); !ok { + return + } + if !slices.ContainsFunc(unitTypes, func(unitType unit.Type) bool { + if !ctx.CodespaceTokenAllowsRepository(unitType, mode) { + return false + } + if mode == perm.AccessModeWrite { + return ctx.Repo.Permission.CanWrite(unitType) + } + return ctx.Repo.Permission.CanRead(unitType) + }) { + ctx.APIError(http.StatusForbidden, "codespace token does not have the required repository permission") + } +} + +func reqCodespaceTokenRepositoryPermission(unitTypes ...unit.Type) func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + mode := perm.AccessModeRead + switch ctx.Req.Method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + mode = perm.AccessModeWrite + } + requireCodespaceTokenRepositoryPermission(ctx, mode, unitTypes...) + } +} + +func reqCodespaceTokenRepositoryRead(unitTypes ...unit.Type) func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + requireCodespaceTokenRepositoryPermission(ctx, perm.AccessModeRead, unitTypes...) + } +} + // reqAnyRepoReader user should have any permission to read repository or permissions of site admin func reqAnyRepoReader() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { @@ -849,8 +963,15 @@ func buildAuthGroup() *auth.Group { func apiAuth(authMethod auth.Method) func(*context.APIContext) { return func(ctx *context.APIContext) { + if codespaceTokenRoutePolicy(ctx) == codespaceTokenRoutePolicySignedArtifact { + return + } ar, err := common.AuthShared(ctx.Base, nil, authMethod) if err != nil { + if auth.IsCodespaceTokenForbidden(err) { + ctx.APIError(http.StatusForbidden, "codespace token is not allowed for this request") + return + } msg, ok := auth.ErrAsUserAuthMessage(err) msg = util.Iif(ok, msg, "invalid username, password or token") ctx.APIError(http.StatusUnauthorized, msg) @@ -865,6 +986,9 @@ func apiAuth(authMethod auth.Method) func(*context.APIContext) { // verifyAuthWithOptions checks authentication according to options func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIContext) { return func(ctx *context.APIContext) { + if codespaceTokenRoutePolicy(ctx) == codespaceTokenRoutePolicySignedArtifact { + return + } // Check prohibit login users. if ctx.IsSigned { if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { @@ -971,14 +1095,14 @@ func Routes() *web.Router { // Get user from session if logged in. m.AfterRouting(apiAuth(buildAuthGroup())) + m.AfterRouting(codespaceTokenRouteGuard) m.AfterRouting(verifyAuthWithOptions(&common.VerifyOptions{ SignInRequired: setting.Service.RequireSignInViewStrict, })) - addActionsRoutes := func( + addActionsManagementRoutes := func( m *web.Router, - reqReaderCheck func(ctx *context.APIContext), reqOwnerCheck func(ctx *context.APIContext), act actions.API, ) { @@ -1006,6 +1130,14 @@ func Routes() *web.Router { m.Delete("/{runner_id}", reqToken(), reqOwnerCheck, act.DeleteRunner) m.Patch("/{runner_id}", reqToken(), reqOwnerCheck, bind(api.EditActionRunnerOption{}), act.UpdateRunner) }) + }) + } + addActionsReaderRoutes := func( + m *web.Router, + reqReaderCheck func(ctx *context.APIContext), + act actions.API, + ) { + m.Group("/actions", func() { m.Get("/runs", reqToken(), reqReaderCheck, act.ListWorkflowRuns) m.Get("/jobs", reqToken(), reqReaderCheck, act.ListWorkflowJobs) }) @@ -1026,9 +1158,9 @@ func Routes() *web.Router { // Misc (public accessible) m.Group("", func() { - m.Get("/version", misc.Version) - m.Get("/signing-key.gpg", misc.SigningKeyGPG) - m.Get("/signing-key.pub", misc.SigningKeySSH) + m.Get("/version", codespaceTokenRoute(codespaceTokenRoutePolicyPublicInfo), misc.Version) + m.Get("/signing-key.gpg", codespaceTokenRoute(codespaceTokenRoutePolicyPublicInfo), misc.SigningKeyGPG) + m.Get("/signing-key.pub", codespaceTokenRoute(codespaceTokenRoutePolicyPublicInfo), misc.SigningKeySSH) m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup) m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown) m.Post("/markdown/raw", reqToken(), misc.MarkdownRaw) @@ -1107,7 +1239,7 @@ func Routes() *web.Router { // Users (requires user scope) m.Group("/user", func() { - m.Get("", user.GetAuthenticatedUser) + m.Get("", codespaceTokenRoute(codespaceTokenRoutePolicySelf), user.GetAuthenticatedUser) m.Group("/settings", func() { m.Get("", user.GetUserSettings) m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings) @@ -1246,9 +1378,10 @@ func Routes() *web.Router { m.Post("/migrate", reqToken(), bind(api.MigrateRepoOptions{}), repo.Migrate) m.Group("/{username}/{reponame}", func() { - m.Get("/compare/*", reqRepoReader(unit.TypeCode), repo.CompareDiff) + m.Get("/compare/*", codespaceTokenRepositoryRoute, reqRepoReader(unit.TypeCode), repo.CompareDiff) - m.Combo("").Get(reqAnyRepoReader(), repo.Get). + m.Get("", codespaceTokenRepositoryRoute, reqAnyRepoReader(), reqCodespaceTokenRepositoryPermission(unit.TypeCode), repo.Get) + m.Combo(""). Delete(reqToken(), reqOwner(), repo.Delete). Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit) m.Post("/generate", reqToken(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate) @@ -1259,7 +1392,11 @@ func Routes() *web.Router { }, reqToken()) // Adds the routes for secrets/variables and runner management - addActionsRoutes(m, reqRepoReader(unit.TypeActions), reqOwner(), repo.NewAction()) + repoActionsAPI := repo.NewAction() + addActionsManagementRoutes(m, reqOwner(), repoActionsAPI) + m.Group("", func() { + addActionsReaderRoutes(m, reqRepoReader(unit.TypeActions), repoActionsAPI) + }, codespaceTokenRepositoryRoute) m.Group("/actions/workflows", func() { m.Get("", repo.ActionsListRepositoryWorkflows) @@ -1268,12 +1405,12 @@ func Routes() *web.Router { m.Put("/{workflow_id}/disable", reqRepoWriter(unit.TypeActions), repo.ActionsDisableWorkflow) m.Put("/{workflow_id}/enable", reqRepoWriter(unit.TypeActions), repo.ActionsEnableWorkflow) m.Post("/{workflow_id}/dispatches", reqRepoWriter(unit.TypeActions), bind(api.CreateActionWorkflowDispatch{}), repo.ActionsDispatchWorkflow) - }, context.ReferencesGitRepo(), reqToken(), reqRepoReader(unit.TypeActions)) + }, context.ReferencesGitRepo(), reqToken(), reqRepoReader(unit.TypeActions), codespaceTokenRepositoryRoute) m.Group("/actions/jobs", func() { m.Get("/{job_id}", repo.GetWorkflowJob) m.Get("/{job_id}/logs", repo.DownloadActionsRunJobLogs) - }, reqToken(), reqRepoReader(unit.TypeActions)) + }, reqToken(), reqRepoReader(unit.TypeActions), codespaceTokenRepositoryRoute) m.Group("/hooks/git", func() { m.Combo("").Get(repo.ListGitHooks) @@ -1302,21 +1439,21 @@ func Routes() *web.Router { m.Get("/permission", repo.GetRepoPermissions) }) }, reqToken()) - m.Get("/assignees", reqToken(), reqAnyRepoReader(), repo.GetAssignees) - m.Get("/assignees/{assignee}", reqToken(), reqAnyRepoReader(), repo.CheckRepoIssueAssignee) - m.Get("/reviewers", reqToken(), reqAnyRepoReader(), repo.GetReviewers) + m.Get("/assignees", codespaceTokenRepositoryRoute, reqToken(), reqCodespaceTokenRepositoryPermission(unit.TypeIssues, unit.TypePullRequests), reqAnyRepoReader(), repo.GetAssignees) + m.Get("/assignees/{assignee}", codespaceTokenRepositoryRoute, reqToken(), reqCodespaceTokenRepositoryPermission(unit.TypeIssues, unit.TypePullRequests), reqAnyRepoReader(), repo.CheckRepoIssueAssignee) + m.Get("/reviewers", codespaceTokenRepositoryRoute, reqToken(), reqCodespaceTokenRepositoryPermission(unit.TypePullRequests), reqAnyRepoReader(), repo.GetReviewers) m.Group("/teams", func() { m.Get("", reqAnyRepoReader(), repo.ListTeams) m.Combo("/{team}").Get(reqAnyRepoReader(), repo.IsTeam). Put(reqAdmin(), repo.AddTeam). Delete(reqAdmin(), repo.DeleteTeam) }, reqToken()) - m.Get("/raw/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFile) - m.Get("/media/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFileOrLFS) - m.Methods("HEAD,GET", "/archive/*", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true), repo.GetArchive) - m.Combo("/forks").Get(repo.ListForks). - Post(reqToken(), reqRepoReader(unit.TypeCode), bind(api.CreateForkOption{}), repo.CreateFork) - m.Post("/merge-upstream", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeCode), bind(api.MergeUpstreamRequest{}), repo.MergeUpstream) + m.Get("/raw/*", codespaceTokenRepositoryRoute, context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFile) + m.Get("/media/*", codespaceTokenRepositoryRoute, context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFileOrLFS) + m.Methods("HEAD,GET", "/archive/*", codespaceTokenRepositoryRoute, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true), repo.GetArchive) + m.Get("/forks", codespaceTokenRepositoryRoute, reqRepoReader(unit.TypeCode), repo.ListForks) + m.Post("/forks", reqToken(), reqRepoReader(unit.TypeCode), bind(api.CreateForkOption{}), repo.CreateFork) + m.Post("/merge-upstream", codespaceTokenRepositoryRoute, reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeCode), bind(api.MergeUpstreamRequest{}), repo.MergeUpstream) m.Group("/branches", func() { m.Get("", repo.ListBranches) m.Get("/*", repo.GetBranch) @@ -1324,7 +1461,7 @@ func Routes() *web.Router { m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateBranchRepoOption{}), repo.CreateBranch) m.Put("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.UpdateBranchRepoOption{}), repo.UpdateBranch) m.Patch("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.RenameBranchRepoOption{}), repo.RenameBranch) - }, context.ReferencesGitRepo(), reqRepoReader(unit.TypeCode)) + }, context.ReferencesGitRepo(), reqRepoReader(unit.TypeCode), codespaceTokenRepositoryRoute) m.Group("/branch_protections", func() { m.Get("", repo.ListBranchProtections) m.Post("", bind(api.CreateBranchProtectionOption{}), mustNotBeArchived, repo.CreateBranchProtection) @@ -1340,7 +1477,7 @@ func Routes() *web.Router { m.Get("/*", repo.GetTag) m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateTagOption{}), repo.CreateTag) m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteTag) - }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true)) + }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true), codespaceTokenRepositoryRoute) m.Group("/tag_protections", func() { m.Combo("").Get(repo.ListTagProtection). Post(bind(api.CreateTagProtectionOption{}), mustNotBeArchived, repo.CreateTagProtection) @@ -1373,7 +1510,7 @@ func Routes() *web.Router { m.Delete("", reqRepoWriter(unit.TypeActions), repo.DeleteArtifact) }) m.Get("/artifacts/{artifact_id}/zip", repo.DownloadArtifact) - }, reqRepoReader(unit.TypeActions)) + }, reqRepoReader(unit.TypeActions), codespaceTokenRepositoryRoute) m.Group("/keys", func() { m.Combo("").Get(repo.ListDeployKeys). Post(bind(api.CreateKeyOption{}), repo.CreateDeployKey) @@ -1383,7 +1520,7 @@ func Routes() *web.Router { m.Group("/times", func() { m.Combo("").Get(repo.ListTrackedTimesByRepository) m.Combo("/{timetrackingusername}").Get(repo.ListTrackedTimesByUser) - }, mustEnableIssues, reqToken()) + }, mustEnableIssues, reqToken(), reqCodespaceTokenRepositoryPermission(unit.TypeIssues, unit.TypePullRequests), codespaceTokenRepositoryRoute) m.Group("/wiki", func() { m.Combo("/page/{pageName}"). Get(repo.GetWikiPage). @@ -1392,10 +1529,10 @@ func Routes() *web.Router { m.Get("/revisions/{pageName}", repo.ListPageRevisions) m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.NewWikiPage) m.Get("/pages", repo.ListWikiPages) - }, mustEnableWiki) - m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup) - m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown) - m.Post("/markdown/raw", reqToken(), misc.MarkdownRaw) + }, mustEnableWiki, reqCodespaceTokenRepositoryPermission(unit.TypeWiki), codespaceTokenRepositoryRoute) + m.Post("/markup", codespaceTokenRepositoryRoute, reqToken(), reqCodespaceTokenRepositoryRead(unit.TypeCode), bind(api.MarkupOption{}), misc.Markup) + m.Post("/markdown", codespaceTokenRepositoryRoute, reqToken(), reqCodespaceTokenRepositoryRead(unit.TypeCode), bind(api.MarkdownOption{}), misc.Markdown) + m.Post("/markdown/raw", codespaceTokenRepositoryRoute, reqToken(), reqCodespaceTokenRepositoryRead(unit.TypeCode), misc.MarkdownRaw) m.Get("/stargazers", reqStarsEnabled(), repo.ListStargazers) m.Get("/subscribers", repo.ListSubscribers) m.Group("/subscription", func() { @@ -1424,8 +1561,8 @@ func Routes() *web.Router { Get(repo.GetReleaseByTag). Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag) }) - }, reqRepoReader(unit.TypeReleases)) - m.Post("/mirror-sync", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.MirrorSync) + }, reqRepoReader(unit.TypeReleases), codespaceTokenRepositoryRoute) + m.Post("/mirror-sync", codespaceTokenRepositoryRoute, reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.MirrorSync) m.Post("/push_mirrors-sync", reqAdmin(), reqToken(), mustNotBeArchived, repo.PushMirrorSync) m.Group("/push_mirrors", func() { m.Combo("").Get(repo.ListPushMirrors). @@ -1435,7 +1572,7 @@ func Routes() *web.Router { Get(repo.GetPushMirrorByName) }, reqAdmin(), reqToken()) - m.Get("/editorconfig/{filename}", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetEditorconfig) + m.Get("/editorconfig/{filename}", codespaceTokenRepositoryRoute, context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetEditorconfig) m.Group("/pulls", func() { m.Combo("").Get(repo.ListPullRequests). Post(reqToken(), mustNotBeArchived, bind(api.CreatePullRequestOption{}), repo.CreatePullRequest) @@ -1473,11 +1610,11 @@ func Routes() *web.Router { m.Post("/comments/{id}/replies", reqToken(), mustNotBeArchived, bind(api.CreatePullReviewCommentReplyOptions{}), repo.CreatePullReviewCommentReply) }) m.Get("/{base}/*", repo.GetPullRequestByBaseHead) - }, mustAllowPulls, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()) + }, mustAllowPulls, reqRepoReader(unit.TypeCode), reqCodespaceTokenRepositoryPermission(unit.TypePullRequests), context.ReferencesGitRepo(), codespaceTokenRepositoryRoute) m.Group("/statuses", func() { // "/statuses/{sha}" only accepts commit ID m.Combo("/{sha}").Get(repo.GetCommitStatuses). Post(reqToken(), reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus) - }, reqRepoReader(unit.TypeCode)) + }, reqRepoReader(unit.TypeCode), codespaceTokenRepositoryRoute) m.Group("/commits", func() { m.Get("", context.ReferencesGitRepo(), repo.GetAllCommits) m.PathGroup("/*", func(g *web.RouterPathGroup) { @@ -1487,7 +1624,7 @@ func Routes() *web.Router { g.MatchPath("GET", "//statuses", repo.GetCommitStatusesByRef) g.MatchPath("GET", "//pull", repo.GetCommitPullRequest) }) - }, reqRepoReader(unit.TypeCode)) + }, reqRepoReader(unit.TypeCode), codespaceTokenRepositoryRoute) m.Group("/git", func() { m.Group("/commits", func() { m.Get("/{sha}", repo.GetSingleCommit) @@ -1499,8 +1636,8 @@ func Routes() *web.Router { m.Get("/blobs/{sha}", repo.GetBlob) m.Get("/tags/{sha}", repo.GetAnnotatedTag) m.Get("/notes/{sha}", repo.GetNote) - }, context.ReferencesGitRepo(true), reqRepoReader(unit.TypeCode)) - m.Post("/diffpatch", mustEnableEditor, reqToken(), bind(api.ApplyDiffPatchFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.ApplyDiffPatch) + }, context.ReferencesGitRepo(true), reqRepoReader(unit.TypeCode), codespaceTokenRepositoryRoute) + m.Post("/diffpatch", codespaceTokenRepositoryRoute, mustEnableEditor, reqToken(), reqRepoWriter(unit.TypeCode), bind(api.ApplyDiffPatchFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.ApplyDiffPatch) m.Group("/contents", func() { m.Get("", repo.GetContentsList) m.Get("/*", repo.GetContents) @@ -1512,17 +1649,17 @@ func Routes() *web.Router { m.Put("", bind(api.UpdateFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.UpdateFile) m.Delete("", bind(api.DeleteFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.DeleteFile) }) - }, mustEnableEditor, reqToken()) - }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()) + }, mustEnableEditor, reqToken(), reqRepoWriter(unit.TypeCode)) + }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), codespaceTokenRepositoryRoute) m.Group("/contents-ext", func() { m.Get("", repo.GetContentsExt) m.Get("/*", repo.GetContentsExt) - }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()) - m.Combo("/file-contents", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()). + }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), codespaceTokenRepositoryRoute) + m.Combo("/file-contents", codespaceTokenRepositoryRoute, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()). Get(repo.GetFileContentsGet). - Post(bind(api.GetFilesOptions{}), repo.GetFileContentsPost) // the POST method requires "write" permission, so we also support "GET" method above - m.Get("/signing-key.gpg", misc.SigningKeyGPG) - m.Get("/signing-key.pub", misc.SigningKeySSH) + Post(reqCodespaceTokenRepositoryPermission(unit.TypeCode), bind(api.GetFilesOptions{}), repo.GetFileContentsPost) // the POST method requires "write" permission, so we also support "GET" method above + m.Get("/signing-key.gpg", codespaceTokenRepositoryRoute, reqCodespaceTokenRepositoryPermission(unit.TypeCode), misc.SigningKeyGPG) + m.Get("/signing-key.pub", codespaceTokenRepositoryRoute, reqCodespaceTokenRepositoryPermission(unit.TypeCode), misc.SigningKeySSH) m.Group("/topics", func() { m.Combo("").Get(repo.ListTopics). Put(reqToken(), reqAdmin(), bind(api.RepoTopicOptions{}), repo.UpdateTopics) @@ -1531,25 +1668,25 @@ func Routes() *web.Router { Delete(reqToken(), repo.DeleteTopic) }, reqAdmin()) }, reqAnyRepoReader()) - m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates) - m.Get("/issue_config", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueConfig) - m.Get("/issue_config/validate", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.ValidateIssueConfig) - m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages) - m.Get("/licenses", reqRepoReader(unit.TypeCode), repo.GetLicenses) + m.Get("/issue_templates", codespaceTokenRepositoryRoute, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates) + m.Get("/issue_config", codespaceTokenRepositoryRoute, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueConfig) + m.Get("/issue_config/validate", codespaceTokenRepositoryRoute, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.ValidateIssueConfig) + m.Get("/languages", codespaceTokenRepositoryRoute, reqRepoReader(unit.TypeCode), repo.GetLanguages) + m.Get("/licenses", codespaceTokenRepositoryRoute, reqRepoReader(unit.TypeCode), repo.GetLicenses) m.Get("/activities/feeds", repo.ListRepoActivityFeeds) - m.Get("/new_pin_allowed", repo.AreNewIssuePinsAllowed) + m.Get("/new_pin_allowed", codespaceTokenRepositoryRoute, reqCodespaceTokenRepositoryPermission(unit.TypeIssues, unit.TypePullRequests), repo.AreNewIssuePinsAllowed) m.Group("/avatar", func() { m.Post("", bind(api.UpdateRepoAvatarOption{}), repo.UpdateAvatar) m.Delete("", repo.DeleteAvatar) }, reqAdmin(), reqToken()) - m.Methods("HEAD,GET", "/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true), repo.DownloadArchive) + m.Methods("HEAD,GET", "/{ball_type:tarball|zipball|bundle}/*", codespaceTokenRepositoryRoute, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true), repo.DownloadArchive) }, repoAssignment(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) // Artifacts direct download endpoint authenticates via signed url // it is protected by the "sig" parameter (to help to access private repo), so no need to use other middlewares - m.Get("/repos/{username}/{reponame}/actions/artifacts/{artifact_id}/zip/raw", repo.DownloadArtifactRaw) + m.Get("/repos/{username}/{reponame}/actions/artifacts/{artifact_id}/zip/raw", codespaceTokenRoute(codespaceTokenRoutePolicySignedArtifact), repo.DownloadArtifactRaw) // Notifications (requires notifications scope) m.Group("/repos", func() { @@ -1680,7 +1817,7 @@ func Routes() *web.Router { Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone). Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone) }) - }, repoAssignment(), checkTokenPublicOnly()) + }, repoAssignment(), checkTokenPublicOnly(), codespaceTokenRepositoryRoute, reqCodespaceTokenRepositoryPermission(unit.TypeIssues, unit.TypePullRequests)) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue)) // NOTE: these are Gitea package management API - see packages.CommonRoutes and packages.DockerContainerRoutes for endpoints that implement package manager APIs @@ -1726,12 +1863,9 @@ func Routes() *web.Router { m.Combo("/{username}").Get(reqToken(), org.IsMember). Delete(reqToken(), reqOrgOwnership(), org.DeleteMember) }, reqOrgVisible()) - addActionsRoutes( - m, - reqOrgMembership(), - reqOrgOwnership(), - org.NewAction(), - ) + orgActionsAPI := org.NewAction() + addActionsManagementRoutes(m, reqOrgOwnership(), orgActionsAPI) + addActionsReaderRoutes(m, reqOrgMembership(), orgActionsAPI) m.Group("/public_members", func() { m.Get("", org.ListPublicMembers) m.Combo("/{username}").Get(org.IsPublicMember). diff --git a/routers/api/v1/api_test.go b/routers/api/v1/api_test.go new file mode 100644 index 0000000000000..973b299eb168c --- /dev/null +++ b/routers/api/v1/api_test.go @@ -0,0 +1,127 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1 + +import ( + "net/http" + "testing" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/perm" + "gitea.dev/models/unit" + user_model "gitea.dev/models/user" + "gitea.dev/routers/common" + "gitea.dev/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestSudoRejectsCodespaceToken(t *testing.T) { + for _, tc := range []struct { + name string + path string + set func(header http.Header) + }{ + { + name: "query", + path: "GET /api/v1/user?sudo=user2", + }, + { + name: "header", + path: "GET /api/v1/user", + set: func(header http.Header) { + header.Set("Sudo", "user2") + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, _ := contexttest.MockAPIContext(t, tc.path) + if tc.set != nil { + tc.set(ctx.Req.Header) + } + ctx.IsSigned = true + ctx.Doer = &user_model.User{ID: 1, Name: "admin", IsAdmin: true} + ctx.GetData()[codespace_model.GiteaTokenAuthDataKey] = testCodespaceTokenSnapshot{repoID: 1} + + sudo()(ctx) + + assert.Equal(t, http.StatusForbidden, ctx.Resp.WrittenStatus()) + assert.Equal(t, int64(1), ctx.Doer.ID) + }) + } +} + +func TestCodespaceTokenRouteGuard(t *testing.T) { + for _, tc := range []struct { + name string + policy string + wantStatus int + }{ + { + name: "self", + policy: codespaceTokenRoutePolicySelf, + }, + { + name: "public-info", + policy: codespaceTokenRoutePolicyPublicInfo, + }, + { + name: "signed-artifact", + policy: codespaceTokenRoutePolicySignedArtifact, + }, + { + name: "unmarked-route", + wantStatus: http.StatusForbidden, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, _ := contexttest.MockAPIContext(t, "GET /api/v1/repos/user2/repo1") + ctx.GetData()[codespace_model.GiteaTokenAuthDataKey] = testCodespaceTokenSnapshot{repoID: 1} + if tc.policy != "" { + ctx.GetData()[codespaceTokenRoutePolicyDataKey] = tc.policy + } + + codespaceTokenRouteGuard(ctx) + + assert.Equal(t, tc.wantStatus, ctx.Resp.WrittenStatus()) + }) + } + + t.Run("repository-route", func(t *testing.T) { + ctx, _ := contexttest.MockAPIContext(t, "GET /api/v1/repos/user2/repo1") + ctx.GetData()[codespace_model.GiteaTokenAuthDataKey] = testCodespaceTokenSnapshot{repoID: 1} + ctx.GetData()[codespaceTokenRepositoryRouteDataKey] = true + codespaceTokenRouteGuard(ctx) + assert.Equal(t, 0, ctx.Resp.WrittenStatus()) + }) +} + +func TestSignedArtifactRouteSkipsRegularAPIAuth(t *testing.T) { + ctx, _ := contexttest.MockAPIContext(t, "GET /api/v1/repos/user2/repo1/actions/artifacts/1/zip/raw") + ctx.Req.Header.Set("Authorization", "Bearer gcs_invalid") + ctx.GetData()[codespaceTokenRoutePolicyDataKey] = codespaceTokenRoutePolicySignedArtifact + + apiAuth(buildAuthGroup())(ctx) + assert.Equal(t, 0, ctx.Resp.WrittenStatus()) + assert.Nil(t, ctx.Doer) + + verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true})(ctx) + assert.Equal(t, 0, ctx.Resp.WrittenStatus()) +} + +type testCodespaceTokenSnapshot struct { + repoID int64 +} + +func (s testCodespaceTokenSnapshot) CodespaceTokenRepoID() int64 { + return s.repoID +} + +func (s testCodespaceTokenSnapshot) CodespaceTokenAllowsAnyRepository(repoID int64) bool { + return repoID == s.repoID +} + +func (s testCodespaceTokenSnapshot) CodespaceTokenAllowsRepository(repoID int64, _ unit.Type, _ perm.AccessMode) bool { + return repoID == s.repoID +} diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index cbc7caa1f3560..5e366f7c66807 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -14,6 +14,7 @@ import ( git_model "gitea.dev/models/git" issues_model "gitea.dev/models/issues" + "gitea.dev/models/perm" access_model "gitea.dev/models/perm/access" pull_model "gitea.dev/models/pull" repo_model "gitea.dev/models/repo" @@ -934,6 +935,10 @@ func MergePullRequest(ctx *context.APIContext) { ctx.APIErrorInternal(err) return } + codespaceHeadPermission, ok := requireCodespacePullHeadPermission(ctx, ctx.Repo.Repository, pr.HeadRepo, perm.AccessModeRead) + if !ok { + return + } if err := pr.LoadIssue(ctx); err != nil { ctx.APIErrorInternal(err) @@ -1024,6 +1029,12 @@ func MergePullRequest(ctx *context.APIContext) { ctx.APIErrorInternal(err) return } + if deleteBranchAfterMerge && codespaceHeadPermission != nil { + if !ctx.CodespaceTokenAllowsRepositoryID(pr.HeadRepoID, unit.TypeCode, perm.AccessModeWrite) || !codespaceHeadPermission.CanWrite(unit.TypeCode) { + ctx.APIError(http.StatusForbidden, "codespace token does not have write access to delete the pull request head branch") + return + } + } if form.MergeWhenChecksSucceed { scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message, deleteBranchAfterMerge) @@ -1100,6 +1111,10 @@ func parseCompareInfo(ctx *context.APIContext, compareParam string) (result *git } isSameRepo := baseRepo.ID == headRepo.ID + codespaceHeadPermission, ok := requireCodespacePullHeadPermission(ctx, baseRepo, headRepo, perm.AccessModeRead) + if !ok { + return nil, nil + } var headGitRepo *git.Repository if isSameRepo { @@ -1134,10 +1149,14 @@ func parseCompareInfo(ctx *context.APIContext, compareParam string) (result *git // user should have permission to read headRepo's codes // TODO: could the logic be simplified if the headRepo is the same as the baseRepo? Need to think more about it. - permHead, err := access_model.GetDoerRepoPermission(ctx, headRepo, ctx.Doer) - if err != nil { - ctx.APIErrorInternal(err) - return nil, nil + permHead := codespaceHeadPermission + if permHead == nil { + permission, err := access_model.GetDoerRepoPermission(ctx, headRepo, ctx.Doer) + if err != nil { + ctx.APIErrorInternal(err) + return nil, nil + } + permHead = &permission } if !permHead.CanRead(unit.TypeCode) { log.Trace("Permission Denied: User: %-v cannot read code in Repo: %-v\nUser in headRepo has Permissions: %-+v", ctx.Doer, headRepo, permHead) @@ -1175,6 +1194,65 @@ func parseCompareInfo(ctx *context.APIContext, compareParam string) (result *git return &compareInfo, closer } +func repositoriesShareForkTree(ctx *context.APIContext, first, second *repo_model.Repository) (bool, error) { + rootID := func(repo *repo_model.Repository) (int64, error) { + const maxForkDepth = 10 + for range maxForkDepth { + if !repo.IsFork { + return repo.ID, nil + } + base, err := repo_model.GetRepositoryByID(ctx, repo.ForkID) + if err != nil { + return 0, err + } + repo = base + } + return 0, errors.New("repository fork tree is too deep") + } + firstRootID, err := rootID(first) + if err != nil { + return false, err + } + secondRootID, err := rootID(second) + return firstRootID == secondRootID, err +} + +func requireCodespacePullHeadPermission(ctx *context.APIContext, baseRepo, headRepo *repo_model.Repository, mode perm.AccessMode) (*access_model.Permission, bool) { + if baseRepo.ID == headRepo.ID { + return nil, true + } + if _, ok := ctx.CodespaceTokenRepoID(); !ok { + return nil, true + } + if !ctx.CodespaceTokenAllowsRepositoryID(headRepo.ID, unit.TypeCode, mode) { + ctx.APIError(http.StatusForbidden, "codespace token does not have access to the pull request head repository") + return nil, false + } + permission, err := access_model.GetDoerRepoPermission(ctx, headRepo, ctx.Doer) + if err != nil { + ctx.APIErrorInternal(err) + return nil, false + } + allowed := permission.CanRead(unit.TypeCode) + if mode == perm.AccessModeWrite { + allowed = permission.CanWrite(unit.TypeCode) + } + if !allowed { + ctx.APIError(http.StatusForbidden, "user does not have access to the pull request head repository") + return nil, false + } + related, err := repositoriesShareForkTree(ctx, baseRepo, headRepo) + if err != nil { + ctx.APIErrorInternal(err) + return nil, false + } + if !related { + ctx.APIError(http.StatusForbidden, "pull request repositories are not in the same fork tree") + return nil, false + } + return &permission, true +} + // UpdatePullRequest merge PR's baseBranch into headBranch func UpdatePullRequest(ctx *context.APIContext) { // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/update repository repoUpdatePullRequest @@ -1249,10 +1327,14 @@ func UpdatePullRequest(ctx *context.APIContext) { ctx.APIErrorInternal(err) return } + if _, ok := requireCodespacePullHeadPermission(ctx, pr.BaseRepo, pr.HeadRepo, perm.AccessModeWrite); !ok { + return + } - // a public-only token must not update (push into) a private head repo, - // even when the base repo named in the route is public - if !ctx.TokenCanAccessRepo(pr.HeadRepo) { + // A public-only token must not update a private head repo, even when the + // base repo named in the route is public. Codespace head permission was + // checked above; public-only scope remains an independent Gitea API rule. + if ctx.PublicOnly && !ctx.TokenCanAccessRepo(pr.HeadRepo) { ctx.APIErrorNotFound() return } diff --git a/routers/api/v1/user/key.go b/routers/api/v1/user/key.go index c12e98ca4c87f..f2efe1ec3818a 100644 --- a/routers/api/v1/user/key.go +++ b/routers/api/v1/user/key.go @@ -69,13 +69,14 @@ func listPublicKeys(ctx *context.APIContext, user *user_model.User) { ListOptions: listOptions, OwnerID: userID, Fingerprint: fingerprint, + KeyTypes: []asymkey_model.KeyType{asymkey_model.KeyTypeUser, asymkey_model.KeyTypeDeploy}, }) } else { // Use ListPublicKeys keys, count, err = db.FindAndCount[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ ListOptions: listOptions, OwnerID: user.ID, - NotKeytype: asymkey_model.KeyTypePrincipal, + KeyTypes: []asymkey_model.KeyType{asymkey_model.KeyTypeUser}, }) } diff --git a/routers/init.go b/routers/init.go index 483b61726ac7c..818a17dfc9606 100644 --- a/routers/init.go +++ b/routers/init.go @@ -27,6 +27,7 @@ import ( "gitea.dev/modules/web" "gitea.dev/modules/web/routing" actions_router "gitea.dev/routers/api/actions" + codespace_router "gitea.dev/routers/api/codespace" packages_router "gitea.dev/routers/api/packages" apiv1 "gitea.dev/routers/api/v1" "gitea.dev/routers/common" @@ -37,6 +38,7 @@ import ( "gitea.dev/services/auth" "gitea.dev/services/auth/source/oauth2" "gitea.dev/services/automerge" + codespace_service "gitea.dev/services/codespace" "gitea.dev/services/cron" feed_service "gitea.dev/services/feed" indexer_service "gitea.dev/services/indexer" @@ -161,6 +163,10 @@ func InitWebInstalled(ctx context.Context) { mustInitCtx(ctx, syncAppConfForGit) mustInit(ssh.Init) + if err := codespace_service.Init(ctx); err != nil { + setting.Codespace.Enabled = false + log.Error("Codespace is disabled because its configuration is invalid: %v", err) + } auth.Init() mustInit(svg.Init) @@ -207,6 +213,9 @@ func NormalRoutes() *web.Router { r.Mount(prefix, actions_router.ArtifactsV4Routes(prefix)) } + prefix := "/api/codespace" + r.Mount(prefix, codespace_router.Routes(prefix)) + r.NotFound(func(w http.ResponseWriter, req *http.Request) { defer routing.RecordFuncInfo(req.Context(), routing.GetFuncInfo(http.NotFound, "GlobalNotFound"))() http.NotFound(w, req) diff --git a/routers/private/serv.go b/routers/private/serv.go index 61a91a4d99bcb..d02dc00276b50 100644 --- a/routers/private/serv.go +++ b/routers/private/serv.go @@ -4,6 +4,8 @@ package private import ( + "errors" + "fmt" "net/http" "strings" @@ -19,6 +21,7 @@ import ( "gitea.dev/modules/private" "gitea.dev/modules/setting" "gitea.dev/modules/util" + codespace_service "gitea.dev/services/codespace" "gitea.dev/services/context" repo_service "gitea.dev/services/repository" wiki_service "gitea.dev/services/wiki" @@ -44,7 +47,8 @@ func ServNoCommand(ctx *context.PrivateContext) { } results.Key = key - if key.Type == asymkey_model.KeyTypeUser || key.Type == asymkey_model.KeyTypePrincipal { + switch key.Type { + case asymkey_model.KeyTypeUser, asymkey_model.KeyTypePrincipal: user, err := user_model.GetUserByID(ctx, key.OwnerID) if err != nil { if user_model.IsErrUserNotExist(err) { @@ -59,6 +63,12 @@ func ServNoCommand(ctx *context.PrivateContext) { return } results.Owner = user + case asymkey_model.KeyTypeDeploy, asymkey_model.KeyTypeCodespace: + default: + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unsupported public key type %d for key: %d", key.Type, keyID), + }) + return } ctx.JSON(http.StatusOK, &results) } @@ -188,16 +198,20 @@ func ServCommand(ctx *context.PrivateContext) { results.KeyID = key.ID results.UserID = key.OwnerID + // If repo doesn't exist, deploy and Codespace keys don't make sense + if repo == nil && (key.Type == asymkey_model.KeyTypeDeploy || key.Type == asymkey_model.KeyTypeCodespace) { + ctx.PrivateUserErrorf(http.StatusNotFound, "Cannot find repository %s", repoLogName) + return + } + // Deploy Keys have ownerID set to 0 therefore we can't use the owner // So now we need to check if the key is a deploy key // We'll keep hold of the deploy key here for permissions checking var deployKey *asymkey_model.DeployKey var user *user_model.User - if key.Type == asymkey_model.KeyTypeDeploy { - if repo == nil { - ctx.PrivateUserErrorf(http.StatusNotFound, "Cannot find repository %s", repoLogName) - return - } + switch key.Type { + case asymkey_model.KeyTypeDeploy: + var err error deployKey, err = asymkey_model.GetDeployKeyByRepo(ctx, key.ID, repo.ID) if err != nil { if asymkey_model.IsErrDeployKeyNotExist(err) { @@ -218,7 +232,7 @@ func ServCommand(ctx *context.PrivateContext) { if !repo.Owner.KeepEmailPrivate { results.UserEmail = repo.Owner.Email } - } else { + case asymkey_model.KeyTypeUser: // Get the user represented by the Key user, err = user_model.GetUserByID(ctx, key.OwnerID) if err != nil { @@ -239,6 +253,22 @@ func ServCommand(ctx *context.PrivateContext) { if !user.KeepEmailPrivate { results.UserEmail = user.Email } + case asymkey_model.KeyTypeCodespace: + var status int + var response private.Response + user, status, response = loadServCodespaceKeyUser(ctx, key, repo, unitType, mode) + if status != http.StatusOK { + ctx.JSON(status, response) + return + } + results.UserID = user.ID + results.UserName = user.Name + if !user.KeepEmailPrivate { + results.UserEmail = user.Email + } + default: + ctx.PrivateInternalErrorf("Unsupported public key type %d for key: %d", key.Type, key.ID) + return } // Don't allow pushing if the repo is archived @@ -325,3 +355,29 @@ func ServCommand(ctx *context.PrivateContext) { ctx.JSON(http.StatusOK, results) // We will update the keys in a different call. } + +func loadServCodespaceKeyUser(ctx *context.PrivateContext, key *asymkey_model.PublicKey, repo *repo_model.Repository, unitType unit.Type, mode perm.AccessMode) (*user_model.User, int, private.Response) { + if repo == nil { + return nil, http.StatusUnauthorized, private.Response{UserMsg: fmt.Sprintf("Public (Codespace) Key: %d:%s is not authorized for this repository.", key.ID, key.Name)} + } + user, err := codespace_service.ResolveGitSSHKeyUser(ctx, key, repo.ID, unitType, mode) + switch { + case err == nil: + return user, http.StatusOK, private.Response{} + case errors.Is(err, codespace_service.ErrResolveGitSSHKeyBindingNotFound): + return nil, http.StatusUnauthorized, private.Response{UserMsg: fmt.Sprintf("Public (Codespace) Key: %d:%s is not bound to a Codespace.", key.ID, key.Name)} + case errors.Is(err, codespace_service.ErrResolveGitSSHKeyBindingInvalid): + return nil, http.StatusUnauthorized, private.Response{UserMsg: fmt.Sprintf("Public (Codespace) Key: %d:%s has no valid Codespace binding.", key.ID, key.Name)} + case errors.Is(err, codespace_service.ErrResolveGitSSHKeyRepoMismatch): + return nil, http.StatusUnauthorized, private.Response{UserMsg: fmt.Sprintf("Public (Codespace) Key: %d:%s is not authorized for this repository.", key.ID, key.Name)} + case errors.Is(err, codespace_service.ErrResolveGitSSHKeyStateUnavailable): + return nil, http.StatusForbidden, private.Response{UserMsg: "Codespace is not in a state that allows Git SSH access."} + case errors.Is(err, codespace_service.ErrResolveGitSSHKeyUserNotFound): + return nil, http.StatusUnauthorized, private.Response{UserMsg: fmt.Sprintf("Codespace owner does not exist for Public Key: %d:%s.", key.ID, key.Name)} + case errors.Is(err, codespace_service.ErrResolveGitSSHKeyLoginRestricted): + return nil, http.StatusForbidden, private.Response{UserMsg: "Your account is disabled."} + default: + log.Error("Unable to resolve Codespace user for public key: %d:%s Error: %v", key.ID, key.Name, err) + return nil, http.StatusInternalServerError, private.Response{Err: fmt.Sprintf("Unable to resolve Codespace user for Public Key: %d:%s.", key.ID, key.Name)} + } +} diff --git a/routers/web/codespace/action.go b/routers/web/codespace/action.go new file mode 100644 index 0000000000000..c4bbfa5eda43a --- /dev/null +++ b/routers/web/codespace/action.go @@ -0,0 +1,224 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "errors" + "math" + "net/http" + "net/url" + "strconv" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/modules/setting" + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/context" +) + +// Stop queues a user stop operation for the creator's Codespace. +func Stop(ctx *context.Context) { + if ctx.Doer == nil { + ctx.NotFound(nil) + return + } + returnPath := codespaceActionReturnPath(ctx.PathParam("uuid"), ctx.FormString("return_to"), codespaceDetailPath(ctx.PathParam("uuid"))) + _, err := codespace_service.StopCodespace(ctx, lifecycleActionOptions(ctx)) + if err != nil { + handleLifecycleActionError(ctx, "StopCodespace", err, returnPath) + return + } + ctx.Redirect(returnPath, http.StatusSeeOther) +} + +// Resume queues a user resume operation for the creator's Codespace. +func Resume(ctx *context.Context) { + if ctx.Doer == nil { + ctx.NotFound(nil) + return + } + returnPath := codespaceActionReturnPath(ctx.PathParam("uuid"), ctx.FormString("return_to"), codespaceDetailPath(ctx.PathParam("uuid"))) + _, err := codespace_service.ResumeCodespace(ctx, lifecycleActionOptions(ctx)) + if err != nil { + handleLifecycleActionError(ctx, "ResumeCodespace", err, returnPath) + return + } + ctx.Redirect(returnPath, http.StatusSeeOther) +} + +// Delete deletes or queues deletion for the creator's Codespace. +func Delete(ctx *context.Context) { + if ctx.Doer == nil { + ctx.NotFound(nil) + return + } + returnPath := codespaceActionReturnPath(ctx.PathParam("uuid"), ctx.FormString("return_to"), codespaceListPath("", 1)) + _, err := codespace_service.DeleteCodespace(ctx, lifecycleActionOptions(ctx)) + if err != nil { + handleLifecycleActionError(ctx, "DeleteCodespace", err, returnPath) + return + } + ctx.Redirect(returnPath, http.StatusSeeOther) +} + +// Continue records that the creator is still using the running Codespace. +func Continue(ctx *context.Context) { + if ctx.Doer == nil { + ctx.NotFound(nil) + return + } + returnPath := codespaceActionReturnPath(ctx.PathParam("uuid"), ctx.FormString("return_to"), codespaceDetailPath(ctx.PathParam("uuid"))) + _, err := codespace_service.ContinueCodespace(ctx, codespace_service.ContinueCodespaceOptions{ + UserID: ctx.Doer.ID, + CodespaceUUID: ctx.PathParam("uuid"), + }) + if err != nil { + handleInteractionError(ctx, "ContinueCodespace", err, returnPath) + return + } + ctx.Redirect(returnPath, http.StatusSeeOther) +} + +// AutoStop saves the creator's auto-stop setting. +func AutoStop(ctx *context.Context) { + if ctx.Doer == nil { + ctx.NotFound(nil) + return + } + returnPath := codespaceActionReturnPath(ctx.PathParam("uuid"), ctx.FormString("return_to"), codespaceDetailPath(ctx.PathParam("uuid"))) + mode := ctx.FormString("mode") + var timeout int64 + switch mode { + case codespace_model.AutoStopModeDefault, codespace_model.AutoStopModeNever: + case codespace_model.AutoStopModeCustom: + var ok bool + timeout, ok = parseAutoStopTimeoutForm(ctx) + if !ok { + ctx.Flash.Error(ctx.Tr("codespace.auto_stop_invalid_duration")) + ctx.Redirect(returnPath, http.StatusSeeOther) + return + } + default: + ctx.Flash.Error(ctx.Tr("codespace.error.invalid_request")) + ctx.Redirect(returnPath, http.StatusSeeOther) + return + } + _, err := codespace_service.UpdateAutoStop(ctx, codespace_service.UpdateAutoStopOptions{ + UserID: ctx.Doer.ID, + CodespaceUUID: ctx.PathParam("uuid"), + Mode: mode, + CustomTimeoutSeconds: timeout, + }) + if err != nil { + if errors.Is(err, codespace_service.ErrInteractionInvalidArgument) { + ctx.Flash.Error(ctx.Tr("codespace.auto_stop_invalid_range")) + ctx.Redirect(returnPath, http.StatusSeeOther) + return + } + handleInteractionError(ctx, "UpdateAutoStop", err, returnPath) + return + } + ctx.Flash.Success(ctx.Tr("settings.saved_successfully")) + ctx.Redirect(returnPath, http.StatusSeeOther) +} + +func handleInteractionError(ctx *context.Context, name string, err error, returnPath string) { + switch { + case errors.Is(err, codespace_service.ErrInteractionInvalidArgument): + ctx.Flash.Error(ctx.Tr("codespace.error.invalid_request")) + case errors.Is(err, codespace_service.ErrInteractionNotFound), errors.Is(err, codespace_service.ErrInteractionPermissionDenied): + ctx.NotFound(nil) + return + case errors.Is(err, codespace_service.ErrInteractionStateUnavailable): + ctx.Flash.Warning(ctx.Tr("codespace.error.state_unavailable")) + case errors.Is(err, codespace_service.ErrInteractionVersionExhausted): + ctx.Flash.Warning(ctx.Tr("codespace.error.version_exhausted")) + default: + ctx.ServerError(name, err) + return + } + ctx.Redirect(returnPath, http.StatusSeeOther) +} + +func handleLifecycleActionError(ctx *context.Context, name string, err error, returnPath string) { + switch { + case errors.Is(err, codespace_service.ErrLifecycleActionNotFound), errors.Is(err, codespace_service.ErrLifecycleActionPermissionDenied): + ctx.NotFound(nil) + return + case errors.Is(err, codespace_service.ErrLifecycleActionStateUnavailable): + ctx.Flash.Warning(ctx.Tr("codespace.error.state_unavailable")) + case errors.Is(err, codespace_service.ErrLifecycleActionVersionExhausted): + ctx.Flash.Warning(ctx.Tr("codespace.error.version_exhausted")) + default: + ctx.ServerError(name, err) + return + } + ctx.Redirect(returnPath, http.StatusSeeOther) +} + +func lifecycleActionOptions(ctx *context.Context) codespace_service.LifecycleActionOptions { + return codespace_service.LifecycleActionOptions{ + UserID: ctx.Doer.ID, + CodespaceUUID: ctx.PathParam("uuid"), + } +} + +func codespaceDetailPath(codespaceUUID string) string { + return "/-/codespaces/" + codespaceUUID +} + +func codespaceActionReturnPath(codespaceUUID, raw, fallback string) string { + fallback = setting.AppSubURL + fallback + if raw == "" { + return fallback + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "" || parsed.Host != "" || parsed.Fragment != "" { + return fallback + } + listPath := setting.AppSubURL + "/-/codespaces" + detailPath := setting.AppSubURL + codespaceDetailPath(codespaceUUID) + if parsed.Path != listPath && parsed.Path != detailPath { + return fallback + } + return parsed.String() +} + +func codespaceListPath(owner string, page int) string { + values := url.Values{} + if owner != "" { + values.Set("owner", owner) + } + if page > 1 { + values.Set("page", strconv.Itoa(page)) + } + path := "/-/codespaces" + if query := values.Encode(); query != "" { + path += "?" + query + } + return path +} + +func parseAutoStopTimeoutForm(ctx *context.Context) (int64, bool) { + value, err := strconv.ParseInt(ctx.FormString("timeout_value"), 10, 64) + if err != nil || value <= 0 { + return 0, false + } + var multiplier int64 + switch ctx.FormString("timeout_unit") { + case "seconds": + multiplier = 1 + case "minutes": + multiplier = 60 + case "hours": + multiplier = 60 * 60 + case "days": + multiplier = 24 * 60 * 60 + default: + return 0, false + } + if value > math.MaxInt64/multiplier { + return 0, false + } + return value * multiplier, true +} diff --git a/routers/web/codespace/action_test.go b/routers/web/codespace/action_test.go new file mode 100644 index 0000000000000..975ee58592f21 --- /dev/null +++ b/routers/web/codespace/action_test.go @@ -0,0 +1,96 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "net/http" + "testing" + + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestCodespaceActionHelpers(t *testing.T) { + t.Run("return path", func(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", "/-/codespaces/uuid"}, + {"/-/codespaces?state=deleted", "/-/codespaces?state=deleted"}, + {"/-/codespaces/uuid?tab=logs", "/-/codespaces/uuid?tab=logs"}, + {"/-/codespaces/other", "/-/codespaces/uuid"}, + {"https://example.com/", "/-/codespaces/uuid"}, + {"//example.com/", "/-/codespaces/uuid"}, + {"relative", "/-/codespaces/uuid"}, + } + for _, test := range tests { + assert.Equal(t, test.want, codespaceActionReturnPath("uuid", test.input, codespaceDetailPath("uuid"))) + } + assert.Equal(t, "/-/codespaces?owner=org&page=2", codespaceListPath("org", 2)) + }) + + t.Run("auto-stop duration form", func(t *testing.T) { + ctx, _ := contexttest.MockContext(t, "POST /-/codespaces/test/auto-stop") + tests := []struct { + value string + unit string + want int64 + ok bool + }{ + {"30", "seconds", 30, true}, + {"5", "minutes", 300, true}, + {"2", "hours", 7200, true}, + {"7", "days", 604800, true}, + {"0", "minutes", 0, false}, + {"invalid", "minutes", 0, false}, + {"1", "weeks", 0, false}, + {"9223372036854775807", "days", 0, false}, + } + for _, test := range tests { + ctx.Req.Form.Set("timeout_value", test.value) + ctx.Req.Form.Set("timeout_unit", test.unit) + value, ok := parseAutoStopTimeoutForm(ctx) + assert.Equal(t, test.ok, ok) + assert.Equal(t, test.want, value) + } + }) +} + +func TestCodespaceActionErrorResponses(t *testing.T) { + tests := []struct { + name string + err error + interaction bool + status int + redirect bool + }{ + {"invalid interaction", codespace_service.ErrInteractionInvalidArgument, true, http.StatusSeeOther, true}, + {"missing interaction", codespace_service.ErrInteractionNotFound, true, http.StatusNotFound, false}, + {"denied interaction", codespace_service.ErrInteractionPermissionDenied, true, http.StatusNotFound, false}, + {"unavailable interaction", codespace_service.ErrInteractionStateUnavailable, true, http.StatusSeeOther, true}, + {"exhausted interaction", codespace_service.ErrInteractionVersionExhausted, true, http.StatusSeeOther, true}, + {"missing lifecycle", codespace_service.ErrLifecycleActionNotFound, false, http.StatusNotFound, false}, + {"denied lifecycle", codespace_service.ErrLifecycleActionPermissionDenied, false, http.StatusNotFound, false}, + {"unavailable lifecycle", codespace_service.ErrLifecycleActionStateUnavailable, false, http.StatusSeeOther, true}, + {"exhausted lifecycle", codespace_service.ErrLifecycleActionVersionExhausted, false, http.StatusSeeOther, true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/test/action") + ctx.SetPathParam("uuid", "test") + if test.interaction { + handleInteractionError(ctx, "TestAction", test.err, "/-/codespaces/test") + } else { + handleLifecycleActionError(ctx, "TestAction", test.err, "/-/codespaces/test") + } + assert.Equal(t, test.status, resp.Code) + if test.redirect { + assert.Equal(t, "/-/codespaces/test", resp.Header().Get("Location")) + } + }) + } +} diff --git a/routers/web/codespace/create.go b/routers/web/codespace/create.go new file mode 100644 index 0000000000000..666cca92c872f --- /dev/null +++ b/routers/web/codespace/create.go @@ -0,0 +1,147 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "errors" + "html/template" + "net/http" + + "gitea.dev/modules/setting" + "gitea.dev/modules/templates" + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/context" +) + +const tplCodespaceCreateConfirm templates.TplName = "codespace/create_confirm" + +type createPermissionRepository struct { + FullName string + Permissions []codespace_service.CreatePermissionRequest +} + +// RepositoryRedirect redirects repository Codespace collection reads to the repository code page. +func RepositoryRedirect(ctx *context.Context) { + if ctx.Repo == nil || ctx.Repo.Repository == nil { + ctx.NotFound(nil) + return + } + ctx.Redirect(ctx.Repo.RepoLink, http.StatusSeeOther) +} + +// New renders the creation confirmation for a repository and ref. +func New(ctx *context.Context) { + if ctx.Doer == nil || ctx.Repo == nil || ctx.Repo.Repository == nil { + ctx.NotFound(nil) + return + } + opts := codespace_service.CreateCodespaceOptions{ + User: ctx.Doer, + Repo: ctx.Repo.Repository, + RefType: ctx.FormString("ref_type"), + RefName: ctx.FormString("ref_name"), + DevContainerSelection: ctx.FormString("dev_container"), + EnvironmentTag: ctx.FormString("environment_tag"), + } + plan, err := codespace_service.PrepareCodespace(ctx, opts) + if err != nil { + handleCreateError(ctx, err) + return + } + renderCreateConfirm(ctx, http.StatusOK, plan, opts, "") +} + +// Create creates a Codespace after the user reviews the current plan. +func Create(ctx *context.Context) { + if ctx.Doer == nil || ctx.Repo == nil || ctx.Repo.Repository == nil { + ctx.NotFound(nil) + return + } + opts := codespace_service.CreateCodespaceOptions{ + User: ctx.Doer, + Repo: ctx.Repo.Repository, + RefType: ctx.FormString("ref_type"), + RefName: ctx.FormString("ref_name"), + DevContainerSelection: ctx.FormString("dev_container"), + EnvironmentTag: ctx.FormString("environment_tag"), + RequestHash: ctx.FormString("request_hash"), + } + plan, err := codespace_service.PrepareCodespace(ctx, opts) + if err != nil { + handleCreateError(ctx, err) + return + } + opts.PermissionGrants = make(map[string]string, len(plan.Permissions)) + for _, permission := range plan.Permissions { + opts.PermissionGrants[permission.FormName] = ctx.FormString(permission.FormName) + } + opts.RecommendedSecretValues = make(map[string]string, len(plan.RecommendedSecrets)) + opts.RecommendedSecretEnabled = make(map[string]bool, len(plan.RecommendedSecrets)) + for _, secret := range plan.RecommendedSecrets { + opts.RecommendedSecretValues[secret.Name] = ctx.FormString("recommended_secret_value_" + secret.Name) + opts.RecommendedSecretEnabled[secret.Name] = ctx.FormBool("recommended_secret_enable_" + secret.Name) + } + result, err := codespace_service.CreateCodespace(ctx, opts) + if err != nil { + if errors.Is(err, codespace_service.ErrCreateEnvironmentUnavailable) || errors.Is(err, codespace_service.ErrCreateRequestChanged) { + if currentPlan, prepareErr := codespace_service.PrepareCodespace(ctx, opts); prepareErr == nil { + plan = currentPlan + } + errorMessage := ctx.Tr("codespace.error.environment_unavailable") + if errors.Is(err, codespace_service.ErrCreateRequestChanged) { + errorMessage = ctx.Tr("codespace.error.create_request_changed") + } + renderCreateConfirm(ctx, http.StatusUnprocessableEntity, plan, opts, errorMessage) + return + } + handleCreateError(ctx, err) + return + } + ctx.Redirect(setting.AppSubURL+codespaceDetailPath(result.CodespaceUUID), http.StatusSeeOther) +} + +func renderCreateConfirm(ctx *context.Context, status int, plan *codespace_service.CreateCodespacePlan, opts codespace_service.CreateCodespaceOptions, errorMessage template.HTML) { + ctx.RespHeader().Set("Cache-Control", "no-store") + permissionRepositories := make([]createPermissionRepository, 0) + permissionGrants := make(map[string]string, len(plan.Permissions)) + for _, permission := range plan.Permissions { + if len(permissionRepositories) == 0 || permissionRepositories[len(permissionRepositories)-1].FullName != permission.RepositoryFullName { + permissionRepositories = append(permissionRepositories, createPermissionRepository{FullName: permission.RepositoryFullName}) + } + index := len(permissionRepositories) - 1 + permissionRepositories[index].Permissions = append(permissionRepositories[index].Permissions, permission) + permissionGrants[permission.FormName] = permission.ModeName + if value := opts.PermissionGrants[permission.FormName]; value != "" { + permissionGrants[permission.FormName] = value + } + } + secretEnabled := make(map[string]bool, len(plan.RecommendedSecrets)) + for _, secret := range plan.RecommendedSecrets { + secretEnabled[secret.Name] = opts.RecommendedSecretEnabled[secret.Name] + } + + ctx.Data["Title"] = ctx.Tr("codespace.confirm_create") + ctx.Data["CreatePlan"] = plan + ctx.Data["CreatePermissionRepositories"] = permissionRepositories + ctx.Data["CreatePermissionGrants"] = permissionGrants + ctx.Data["CreateRecommendedSecretEnabled"] = secretEnabled + ctx.Data["CreateError"] = errorMessage + ctx.HTML(status, tplCodespaceCreateConfirm) +} + +func handleCreateError(ctx *context.Context, err error) { + switch { + case errors.Is(err, codespace_service.ErrCreatePermissionDenied): + ctx.Flash.Error(ctx.Tr("codespace.error.permission_denied")) + case errors.Is(err, codespace_service.ErrCreateStateUnavailable): + ctx.Flash.Error(ctx.Tr("codespace.error.state_unavailable")) + default: + ctx.Flash.Error(ctx.Tr("codespace.error.invalid_create_request")) + } + if ctx.Repo != nil && ctx.Repo.Repository != nil { + ctx.Redirect(ctx.Repo.RepoLink, http.StatusSeeOther) + return + } + ctx.Redirect(setting.AppSubURL+"/-/codespaces", http.StatusSeeOther) +} diff --git a/routers/web/codespace/governance.go b/routers/web/codespace/governance.go new file mode 100644 index 0000000000000..9158527b28aad --- /dev/null +++ b/routers/web/codespace/governance.go @@ -0,0 +1,83 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + gocontext "context" + "errors" + "net/http" + "strconv" + + "gitea.dev/modules/setting" + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/context" +) + +// AdminStop queues a stop operation from a Manager or unassigned Codespace list. +func AdminStop(ctx *context.Context) { + governanceAction(ctx, codespace_service.StopGovernanceCodespace) +} + +// AdminDelete deletes or queues deletion from a Manager or unassigned Codespace list. +func AdminDelete(ctx *context.Context) { + governanceAction(ctx, codespace_service.DeleteGovernanceCodespace) +} + +// AdminForceDelete physically deletes one Codespace from a Manager or unassigned Codespace list. +func AdminForceDelete(ctx *context.Context) { + opts, redirectTo := governanceTarget(ctx) + if ctx.FormString("confirm") != "force-delete" { + ctx.Flash.Error(ctx.Tr("codespace.error.confirm_required")) + ctx.Redirect(redirectTo, http.StatusSeeOther) + return + } + err := codespace_service.ForceDeleteCodespace(ctx, opts) + if err != nil { + handleGovernanceActionError(ctx, "ForceDeleteCodespace", redirectTo, err) + return + } + ctx.Redirect(redirectTo, http.StatusSeeOther) +} + +type governanceActionFunc func(gocontext.Context, codespace_service.GovernanceActionOptions) (*codespace_service.LifecycleActionResult, error) + +func governanceAction(ctx *context.Context, fn governanceActionFunc) { + opts, redirectTo := governanceTarget(ctx) + _, err := fn(ctx, opts) + if err != nil { + handleGovernanceActionError(ctx, "GovernanceCodespaceAction", redirectTo, err) + return + } + ctx.Redirect(redirectTo, http.StatusSeeOther) +} + +func governanceTarget(ctx *context.Context) (codespace_service.GovernanceActionOptions, string) { + managerID := ctx.PathParamInt64("manager_id") + base := setting.AppSubURL + "/-/admin/codespaces/managers" + if managerID > 0 { + return codespace_service.GovernanceActionOptions{ + CodespaceUUID: ctx.PathParam("uuid"), + ManagerID: managerID, + }, base + "/" + strconv.FormatInt(managerID, 10) + } + return codespace_service.GovernanceActionOptions{ + CodespaceUUID: ctx.PathParam("uuid"), + Unassigned: true, + }, base +} + +func handleGovernanceActionError(ctx *context.Context, name, redirectTo string, err error) { + switch { + case errors.Is(err, codespace_service.ErrGovernanceNotFound): + ctx.NotFound(nil) + case errors.Is(err, codespace_service.ErrGovernanceStateUnavailable): + ctx.Flash.Warning(ctx.Tr("codespace.error.state_unavailable")) + ctx.Redirect(redirectTo, http.StatusSeeOther) + case errors.Is(err, codespace_service.ErrLifecycleActionVersionExhausted): + ctx.Flash.Warning(ctx.Tr("codespace.error.version_exhausted")) + ctx.Redirect(redirectTo, http.StatusSeeOther) + default: + ctx.ServerError(name, err) + } +} diff --git a/routers/web/codespace/log.go b/routers/web/codespace/log.go new file mode 100644 index 0000000000000..7e323e6c2714a --- /dev/null +++ b/routers/web/codespace/log.go @@ -0,0 +1,137 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "errors" + "io" + "net/http" + "strconv" + + "gitea.dev/modules/httplib" + "gitea.dev/modules/log" + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/context" +) + +type logErrorResponse struct { + Category string `json:"category"` + CurrentOffset int64 `json:"current_offset,omitempty"` +} + +// Logs returns one byte-offset based Codespace log page for the creator. +func Logs(ctx *context.Context) { + if ctx.Doer == nil { + ctx.NotFound(nil) + return + } + offset, ok := parseOptionalInt64Query(ctx, "offset", 0) + if !ok { + writeLogError(ctx, http.StatusBadRequest, "invalid_argument", 0) + return + } + limit, ok := parseOptionalInt64Query(ctx, "limit", codespace_service.LogReadMaxBytes) + if !ok { + writeLogError(ctx, http.StatusBadRequest, "invalid_argument", 0) + return + } + result, err := codespace_service.ReadLog(ctx, codespace_service.ReadLogOptions{ + UserID: ctx.Doer.ID, + CodespaceUUID: ctx.PathParam("uuid"), + Offset: offset, + Limit: limit, + }) + if err != nil { + var offsetErr *codespace_service.LogOffsetError + if errors.As(err, &offsetErr) { + if errors.Is(err, codespace_service.ErrReadLogOffsetConflict) { + writeLogError(ctx, http.StatusConflict, "offset_conflict", offsetErr.CurrentOffset) + return + } + if errors.Is(err, codespace_service.ErrReadLogInvalidArgument) { + writeLogError(ctx, http.StatusBadRequest, "invalid_argument", offsetErr.CurrentOffset) + return + } + } + switch { + case errors.Is(err, codespace_service.ErrReadLogInvalidArgument): + writeLogError(ctx, http.StatusBadRequest, "invalid_argument", 0) + case errors.Is(err, codespace_service.ErrReadLogPermissionDenied), errors.Is(err, codespace_service.ErrReadLogNotFound): + writeLogError(ctx, http.StatusNotFound, "codespace_not_found", 0) + default: + ctx.ServerError("ReadLog", err) + } + return + } + ctx.RespHeader().Set("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, result) +} + +// DownloadLogs downloads the creator's Codespace log as plain text. +func DownloadLogs(ctx *context.Context) { + if ctx.Doer == nil { + ctx.NotFound(nil) + return + } + codespaceUUID := ctx.PathParam("uuid") + result, err := codespace_service.ReadLog(ctx, codespace_service.ReadLogOptions{ + UserID: ctx.Doer.ID, + CodespaceUUID: codespaceUUID, + Limit: codespace_service.LogReadMaxBytes, + }) + if err != nil { + handleDownloadLogError(ctx, err) + return + } + + ctx.RespHeader().Set("Cache-Control", "no-store") + ctx.RespHeader().Set("Content-Type", "text/plain; charset=utf-8") + ctx.RespHeader().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(codespaceUUID+".log")) + ctx.Resp.WriteHeader(http.StatusOK) + for { + for _, line := range result.Lines { + if _, err := io.WriteString(ctx.Resp, line.Encoded()); err != nil { + return + } + } + if result.EOF { + return + } + result, err = codespace_service.ReadLog(ctx, codespace_service.ReadLogOptions{ + UserID: ctx.Doer.ID, + CodespaceUUID: codespaceUUID, + Offset: result.NextOffset, + Limit: codespace_service.LogReadMaxBytes, + }) + if err != nil { + log.Error("Read Codespace log %s after response started: %v", codespaceUUID, err) + return + } + } +} + +func handleDownloadLogError(ctx *context.Context, err error) { + switch { + case errors.Is(err, codespace_service.ErrReadLogPermissionDenied), errors.Is(err, codespace_service.ErrReadLogNotFound): + ctx.NotFound(nil) + case errors.Is(err, codespace_service.ErrReadLogInvalidArgument), errors.Is(err, codespace_service.ErrReadLogOffsetConflict): + ctx.HTTPError(http.StatusConflict) + default: + ctx.ServerError("ReadLog", err) + } +} + +func parseOptionalInt64Query(ctx *context.Context, name string, def int64) (int64, bool) { + raw := ctx.Req.URL.Query().Get(name) + if raw == "" { + return def, true + } + value, err := strconv.ParseInt(raw, 10, 64) + return value, err == nil +} + +func writeLogError(ctx *context.Context, status int, category string, currentOffset int64) { + ctx.RespHeader().Set("Cache-Control", "no-store") + ctx.JSON(status, logErrorResponse{Category: category, CurrentOffset: currentOffset}) +} diff --git a/routers/web/codespace/log_test.go b/routers/web/codespace/log_test.go new file mode 100644 index 0000000000000..840c4dfd08585 --- /dev/null +++ b/routers/web/codespace/log_test.go @@ -0,0 +1,224 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "net/http" + "strconv" + "strings" + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + "gitea.dev/modules/json" + "gitea.dev/modules/templates" + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/contexttest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLogsReturnsJSONPage(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertWebOpenManager(t, "https://gateway.example.com") + codespaceUUID := "92909090-9090-4090-8090-909090909090" + insertWebLogCodespace(t, manager.ID, codespaceUUID, 94) + _, err := codespace_service.UpdateLog(t.Context(), manager, codespace_service.UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 94, + Offset: 0, + Lines: []*codespacev1.LogLine{ + {TimestampUnixNano: time.Date(2026, 7, 22, 3, 0, 0, 0, time.UTC).UnixNano(), Message: "first"}, + {TimestampUnixNano: time.Date(2026, 7, 22, 3, 0, 1, 0, time.UTC).UnixNano(), Message: "second"}, + }, + }) + require.NoError(t, err) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs?offset=0&limit=40") + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("uuid", codespaceUUID) + Logs(ctx) + + require.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "no-store", resp.Header().Get("Cache-Control")) + var body codespace_service.ReadLogResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.EqualValues(t, 0, body.Offset) + assert.Positive(t, body.NextOffset) + assert.False(t, body.EOF) + assert.True(t, body.OperationActive) + assert.True(t, body.Truncated) + require.Len(t, body.Lines, 1) + assert.Equal(t, "first", body.Lines[0].Message) + assert.InDelta(t, float64(time.Date(2026, 7, 22, 3, 0, 0, 0, time.UTC).Unix()), body.Lines[0].Timestamp, 1e-9) +} + +func TestLogsRejectsInvalidArgument(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + codespaceUUID := "93909090-9090-4090-8090-909090909090" + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs?offset=bad") + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("uuid", codespaceUUID) + Logs(ctx) + + require.Equal(t, http.StatusBadRequest, resp.Code) + assert.Equal(t, "no-store", resp.Header().Get("Cache-Control")) + assert.Equal(t, "invalid_argument", decodeLogError(t, resp.Body.Bytes()).Category) +} + +func TestLogsHideOtherCreatorCodespace(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertWebOpenManager(t, "https://gateway.example.com") + codespaceUUID := "97909090-9090-4090-8090-909090909090" + insertWebLogCodespace(t, manager.ID, codespaceUUID, 98) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs") + contexttest.LoadUser(t, ctx, 2) + ctx.SetPathParam("uuid", codespaceUUID) + Logs(ctx) + require.Equal(t, http.StatusNotFound, resp.Code) + assert.Equal(t, "codespace_not_found", decodeLogError(t, resp.Body.Bytes()).Category) + + ctx, resp = contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs/download") + contexttest.LoadUser(t, ctx, 2) + ctx.SetPathParam("uuid", codespaceUUID) + DownloadLogs(ctx) + require.Equal(t, http.StatusNotFound, resp.Code) +} + +func TestLogsReportsOffsetConflict(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertWebOpenManager(t, "https://gateway.example.com") + codespaceUUID := "94909090-9090-4090-8090-909090909090" + insertWebLogCodespace(t, manager.ID, codespaceUUID, 95) + result, err := codespace_service.UpdateLog(t.Context(), manager, codespace_service.UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 95, + Offset: 0, + Lines: []*codespacev1.LogLine{{ + TimestampUnixNano: time.Date(2026, 7, 22, 3, 1, 0, 0, time.UTC).UnixNano(), + Message: "first", + }}, + }) + require.NoError(t, err) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs?offset="+strconv.FormatInt(result.NextOffset+1, 10)) + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("uuid", codespaceUUID) + Logs(ctx) + + require.Equal(t, http.StatusConflict, resp.Code) + errBody := decodeLogError(t, resp.Body.Bytes()) + assert.Equal(t, "offset_conflict", errBody.Category) + assert.Equal(t, result.NextOffset, errBody.CurrentOffset) +} + +func TestLogPageAndDownloadUseStoredContent(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertWebOpenManager(t, "https://gateway.example.com") + codespaceUUID := "95909090-9090-4090-8090-909090909090" + insertWebLogCodespace(t, manager.ID, codespaceUUID, 96) + _, err := codespace_service.UpdateLog(t.Context(), manager, codespace_service.UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 96, + Offset: 0, + Lines: []*codespacev1.LogLine{{ + TimestampUnixNano: time.Date(2026, 7, 22, 3, 2, 0, 0, time.UTC).UnixNano(), + Message: "download same", + }}, + }) + require.NoError(t, err) + + detailCtx, detailResp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID, contexttest.MockContextOption{Render: templates.PageRenderer()}) + contexttest.LoadUser(t, detailCtx, 1) + detailCtx.SetPathParam("uuid", codespaceUUID) + Detail(detailCtx) + require.Equal(t, http.StatusOK, detailResp.Code) + assert.Contains(t, detailResp.Body.String(), "data-log-next-offset=\"0\"") + assert.Contains(t, detailResp.Body.String(), "/-/codespaces/"+codespaceUUID+"/logs/download") + + downloadCtx, downloadResp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs/download") + contexttest.LoadUser(t, downloadCtx, 1) + downloadCtx.SetPathParam("uuid", codespaceUUID) + DownloadLogs(downloadCtx) + require.Equal(t, http.StatusOK, downloadResp.Code) + assert.Equal(t, "no-store", downloadResp.Header().Get("Cache-Control")) + assert.Contains(t, downloadResp.Header().Get("Content-Disposition"), codespaceUUID+".log") + assert.Contains(t, downloadResp.Body.String(), "download same\n") +} + +func TestDownloadLogsReadsAllPages(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertWebOpenManager(t, "https://gateway.example.com") + codespaceUUID := "96909090-9090-4090-8090-909090909090" + insertWebLogCodespace(t, manager.ID, codespaceUUID, 97) + lines := make([]*codespacev1.LogLine, 9) + for i := range lines { + lines[i] = &codespacev1.LogLine{ + TimestampUnixNano: time.Date(2026, 7, 22, 3, 3, i, 0, time.UTC).UnixNano(), + Message: strconv.Itoa(i) + strings.Repeat("x", 60*1024), + } + } + _, err := codespace_service.UpdateLog(t.Context(), manager, codespace_service.UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 97, + Offset: 0, + Lines: lines, + }) + require.NoError(t, err) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs/download") + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("uuid", codespaceUUID) + DownloadLogs(ctx) + + require.Equal(t, http.StatusOK, resp.Code) + assert.Greater(t, resp.Body.Len(), int(codespace_service.LogReadMaxBytes)) + body := resp.Body.String() + for i := range lines { + assert.Contains(t, body, "] "+strconv.Itoa(i)+strings.Repeat("x", 32)) + } +} + +func insertWebLogCodespace(t *testing.T, managerID int64, codespaceUUID string, operationRVersion int64) { + t.Helper() + require.NoError(t, db.Insert(t.Context(), &codespace_model.Codespace{ + UUID: codespaceUUID, + UserID: 1, + RepoID: 2, + RefType: "branch", + RefName: "main", + EnvironmentTag: "default", + CommitSHA: "0123456789abcdef0123456789abcdef01234567", + ManagerID: managerID, + Status: codespace_model.StatusCreating, + OperationRVersion: operationRVersion, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + AutoStopMode: codespace_model.AutoStopModeDefault, + CreatedUnix: 1, + UpdatedUnix: 1, + })) +} + +func decodeLogError(t *testing.T, data []byte) logErrorResponse { + t.Helper() + var body logErrorResponse + require.NoError(t, json.Unmarshal(data, &body)) + return body +} diff --git a/routers/web/codespace/main_test.go b/routers/web/codespace/main_test.go new file mode 100644 index 0000000000000..2bbf7d731f95e --- /dev/null +++ b/routers/web/codespace/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + + "gitea.dev/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/codespace/open.go b/routers/web/codespace/open.go new file mode 100644 index 0000000000000..d95c1a8f4a98b --- /dev/null +++ b/routers/web/codespace/open.go @@ -0,0 +1,55 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "errors" + "net/http" + + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/context" +) + +// Open redirects an authenticated user to the workspace Gateway open-code exchange. +func Open(ctx *context.Context) { + openEndpoint(ctx, "workspace") +} + +// OpenEndpoint redirects an authenticated user to a specific Endpoint Gateway open-code exchange. +func OpenEndpoint(ctx *context.Context) { + endpointID := ctx.PathParam("endpoint_id") + if endpointID == "workspace" { + ctx.NotFound(nil) + return + } + openEndpoint(ctx, endpointID) +} + +func openEndpoint(ctx *context.Context, endpointID string) { + if ctx.Doer == nil { + ctx.NotFound(nil) + return + } + result, err := codespace_service.OpenEndpoint(ctx, codespace_service.OpenEndpointOptions{ + UserID: ctx.Doer.ID, + CodespaceUUID: ctx.PathParam("uuid"), + EndpointID: endpointID, + }) + if err != nil { + if errors.Is(err, codespace_service.ErrOpenEndpointNotFound) { + ctx.NotFound(nil) + return + } + if errors.Is(err, codespace_service.ErrOpenEndpointUnavailable) { + ctx.Flash.Warning(ctx.Tr("codespace.open.unavailable")) + ctx.Redirect(codespaceDetailPath(ctx.PathParam("uuid")), http.StatusSeeOther) + return + } + ctx.ServerError("OpenEndpoint", err) + return + } + ctx.RespHeader().Set("Cache-Control", "no-store") + ctx.RespHeader().Set("Referrer-Policy", "no-referrer") + ctx.Redirect(result.RedirectURL, http.StatusSeeOther) +} diff --git a/routers/web/codespace/open_test.go b/routers/web/codespace/open_test.go new file mode 100644 index 0000000000000..4f72e42edf11e --- /dev/null +++ b/routers/web/codespace/open_test.go @@ -0,0 +1,164 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "net/http" + "net/url" + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/contexttest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenEndpointRedirectsWithOneTimeCode(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertWebOpenManager(t, "https://gateway.example.com") + codespaceUUID := "96969696-9696-4969-8969-969696969696" + insertWebOpenCodespace(t, manager.ID, codespaceUUID, 91) + require.NoError(t, codespace_service.ReportRuntimeMetadata(t.Context(), manager, codespace_service.ReportRuntimeMetadataOptions{ + CodespaceUUID: codespaceUUID, + Metadata: webOpenRuntimeMetadata(t, 91, []map[string]any{{"endpoint_id": "app-3000", "label": "App", "public": false}}), + MetadataGeneration: 1, + })) + + ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/"+codespaceUUID+"/open/app-3000") + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("endpoint_id", "app-3000") + OpenEndpoint(ctx) + + require.Equal(t, http.StatusSeeOther, resp.Code) + assert.Equal(t, "no-store", resp.Header().Get("Cache-Control")) + assert.Equal(t, "no-referrer", resp.Header().Get("Referrer-Policy")) + location := resp.Header().Get("Location") + parsed, err := url.Parse(location) + require.NoError(t, err) + assert.Equal(t, "https", parsed.Scheme) + assert.Equal(t, "app-3000-96969696969649698969969696969696.gateway.example.com", parsed.Host) + assert.Equal(t, "/.gitea-codespace/open", parsed.Path) + code := parsed.Query().Get("code") + require.Regexp(t, `^[0-9a-f]{64}$`, code) +} + +func TestOpenEndpointPublicRedirectsWithoutCode(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertWebOpenManager(t, "https://gateway.example.com") + codespaceUUID := "98989898-9898-4989-8989-989898989898" + insertWebOpenCodespace(t, manager.ID, codespaceUUID, 92) + require.NoError(t, codespace_service.ReportRuntimeMetadata(t.Context(), manager, codespace_service.ReportRuntimeMetadataOptions{ + CodespaceUUID: codespaceUUID, + Metadata: webOpenRuntimeMetadata(t, 92, []map[string]any{{"endpoint_id": "app-3000", "label": "App", "public": true}}), + MetadataGeneration: 1, + })) + + ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/"+codespaceUUID+"/open/app-3000") + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("endpoint_id", "app-3000") + OpenEndpoint(ctx) + + require.Equal(t, http.StatusSeeOther, resp.Code) + assert.Equal(t, "no-store", resp.Header().Get("Cache-Control")) + assert.Equal(t, "no-referrer", resp.Header().Get("Referrer-Policy")) + location := resp.Header().Get("Location") + parsed, err := url.Parse(location) + require.NoError(t, err) + assert.Equal(t, "https", parsed.Scheme) + assert.Equal(t, "app-3000-98989898989849898989989898989898.gateway.example.com", parsed.Host) + assert.Equal(t, "/", parsed.Path) + assert.Empty(t, parsed.RawQuery) +} + +func TestOpenEndpointHidesOtherCreatorCodespace(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertWebOpenManager(t, "https://gateway.example.com") + codespaceUUID := "99999999-9999-4999-8999-999999999999" + insertWebOpenCodespace(t, manager.ID, codespaceUUID, 93) + + ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/"+codespaceUUID+"/open") + contexttest.LoadUser(t, ctx, 2) + ctx.SetPathParam("uuid", codespaceUUID) + Open(ctx) + + require.Equal(t, http.StatusNotFound, resp.Code) +} + +func insertWebOpenManager(t *testing.T, gatewayURL string) *codespace_model.Manager { + t.Helper() + manager := &codespace_model.Manager{ + Name: "manager", + UserID: 0, + RuntimeState: codespace_model.ManagerRuntimeStateOnline, + TagsJSON: "[]", + CreatedUnix: time.Now().Unix(), + LastOnlineUnix: time.Now().Unix(), + } + manager.GenerateManagerSecret() + require.NoError(t, db.Insert(t.Context(), manager)) + require.NoError(t, db.Insert(t.Context(), &codespace_model.ManagerAddress{ + ManagerID: manager.ID, + Kind: codespace_model.ManagerAddressGateway, + Address: gatewayURL, + })) + return manager +} + +func insertWebOpenCodespace(t *testing.T, managerID int64, codespaceUUID string, operationRVersion int64) { + t.Helper() + require.NoError(t, db.Insert(t.Context(), &codespace_model.Codespace{ + UUID: codespaceUUID, + UserID: 1, + RepoID: 2, + RefType: "branch", + RefName: "main", + EnvironmentTag: "default", + CommitSHA: "0123456789abcdef0123456789abcdef01234567", + ManagerID: managerID, + Status: codespace_model.StatusRunning, + OperationRVersion: operationRVersion, + AutoStopMode: codespace_model.AutoStopModeDefault, + CreatedUnix: 1, + UpdatedUnix: 1, + })) +} + +func webOpenRuntimeMetadata(t *testing.T, operationRVersion int64, endpoints []map[string]any) *codespacev1.RuntimeMetadata { + t.Helper() + metadataEndpoints := make([]*codespacev1.RuntimeEndpoint, 0, len(endpoints)+1) + for _, endpoint := range endpoints { + metadataEndpoints = append(metadataEndpoints, &codespacev1.RuntimeEndpoint{ + EndpointId: endpoint["endpoint_id"].(string), + Label: endpoint["label"].(string), + Public: endpoint["public"].(bool), + }) + } + metadataEndpoints = append(metadataEndpoints, &codespacev1.RuntimeEndpoint{EndpointId: "workspace", Label: "Workspace"}) + return &codespacev1.RuntimeMetadata{ + Endpoints: metadataEndpoints, + Boot: &codespacev1.RuntimeBoot{ + OperationRversion: operationRVersion, + Stage: codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_READY, + StartedUnix: 100, + LastUpdateUnix: 101, + }, + ResourceUsage: &codespacev1.RuntimeResourceUsage{ + Cpu: &codespacev1.RuntimeCPUUsage{UsedMillicores: 125, LimitMillicores: 1000}, + Memory: &codespacev1.RuntimeMemoryUsage{UsedBytes: 256 * 1024 * 1024, LimitBytes: 1024 * 1024 * 1024}, + Disk: &codespacev1.RuntimeDiskUsage{UsedBytes: 512 * 1024 * 1024, LimitBytes: 10 * 1024 * 1024 * 1024}, + ObservedUnix: 101, + }, + } +} diff --git a/routers/web/codespace/settings.go b/routers/web/codespace/settings.go new file mode 100644 index 0000000000000..0fd840955cbe0 --- /dev/null +++ b/routers/web/codespace/settings.go @@ -0,0 +1,216 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "errors" + "net/http" + "strconv" + + "gitea.dev/modules/setting" + "gitea.dev/modules/templates" + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/context" +) + +const ( + tplAdminCodespaceManagers templates.TplName = "codespace/admin_managers" + tplAdminCodespaceManagerDetail templates.TplName = "codespace/admin_manager_detail" + tplUserCodespaceSettings templates.TplName = "codespace/user_settings" + tplUserCodespaceManagerDetail templates.TplName = "codespace/user_manager_detail" +) + +// AdminManagers renders site-wide Manager and global registration token settings. +func AdminManagers(ctx *context.Context) { + renderManagerSettings(ctx, managerSettingsRenderOptions{ + Scope: codespace_service.ManagerSettingsScopeSite, + ActionBase: setting.AppSubURL + "/-/admin/codespaces/managers", + Template: tplAdminCodespaceManagers, + PageFlag: "PageIsAdminCodespaceManagers", + }) +} + +// AdminManager renders one site-visible Manager and its bound Codespaces. +func AdminManager(ctx *context.Context) { + renderManagerDetail(ctx, managerSettingsRenderOptions{ + Scope: codespace_service.ManagerSettingsScopeSite, + ActionBase: setting.AppSubURL + "/-/admin/codespaces/managers", + Template: tplAdminCodespaceManagerDetail, + PageFlag: "PageIsAdminCodespaceManagers", + }) +} + +// AdminManagerDelete removes one site-visible Manager from its management page. +func AdminManagerDelete(ctx *context.Context) { + handleManagerDelete(ctx, managerSettingsRenderOptions{ + Scope: codespace_service.ManagerSettingsScopeSite, + ActionBase: setting.AppSubURL + "/-/admin/codespaces/managers", + }) +} + +// AdminManagersResetRegistrationToken resets the global Manager registration token. +func AdminManagersResetRegistrationToken(ctx *context.Context) { + handleManagerSettingsResetRegistrationToken(ctx, managerSettingsRenderOptions{ + Scope: codespace_service.ManagerSettingsScopeSite, + ActionBase: setting.AppSubURL + "/-/admin/codespaces/managers", + Template: tplAdminCodespaceManagers, + PageFlag: "PageIsAdminCodespaceManagers", + }) +} + +// UserSettings renders current user's Manager and registration token settings. +func UserSettings(ctx *context.Context) { + renderManagerSettings(ctx, managerSettingsRenderOptions{ + Scope: codespace_service.ManagerSettingsScopeUser, + UserID: ctx.Doer.ID, + ActionBase: setting.AppSubURL + "/user/settings/codespaces/managers", + Template: tplUserCodespaceSettings, + PageFlag: "PageIsCodespaceSettings", + }) +} + +// UserManager renders one Manager owned by the current user and its bound Codespaces. +func UserManager(ctx *context.Context) { + renderManagerDetail(ctx, managerSettingsRenderOptions{ + Scope: codespace_service.ManagerSettingsScopeUser, + UserID: ctx.Doer.ID, + ActionBase: setting.AppSubURL + "/user/settings/codespaces/managers", + Template: tplUserCodespaceManagerDetail, + PageFlag: "PageIsCodespaceSettings", + }) +} + +// UserManagerDelete removes one Manager owned by the current user. +func UserManagerDelete(ctx *context.Context) { + handleManagerDelete(ctx, managerSettingsRenderOptions{ + Scope: codespace_service.ManagerSettingsScopeUser, + UserID: ctx.Doer.ID, + ActionBase: setting.AppSubURL + "/user/settings/codespaces/managers", + }) +} + +// UserSettingsResetRegistrationToken resets the current user's Manager registration token. +func UserSettingsResetRegistrationToken(ctx *context.Context) { + handleManagerSettingsResetRegistrationToken(ctx, managerSettingsRenderOptions{ + Scope: codespace_service.ManagerSettingsScopeUser, + UserID: ctx.Doer.ID, + ActionBase: setting.AppSubURL + "/user/settings/codespaces/managers", + Template: tplUserCodespaceSettings, + PageFlag: "PageIsCodespaceSettings", + }) +} + +type managerSettingsRenderOptions struct { + Scope string + UserID int64 + ActionBase string + Template templates.TplName + PageFlag string +} + +func renderManagerSettings(ctx *context.Context, opts managerSettingsRenderOptions) { + settingsView, err := codespace_service.ListManagerSettings(ctx, codespace_service.ManagerSettingsOptions{ + Scope: opts.Scope, + UserID: opts.UserID, + }) + if err != nil { + ctx.ServerError("ListManagerSettings", err) + return + } + ctx.Data["Title"] = "Codespaces" + ctx.Data[opts.PageFlag] = true + ctx.Data["ManagerSettings"] = settingsView + ctx.Data["ManagerTotal"] = len(settingsView.Managers) + ctx.Data["ActionBase"] = opts.ActionBase + ctx.Data["IsSiteManagerSettings"] = opts.Scope == codespace_service.ManagerSettingsScopeSite + if opts.Scope == codespace_service.ManagerSettingsScopeSite { + page := max(ctx.FormInt("page"), 1) + unassigned, err := codespace_service.ListGovernanceCodespaces(ctx, codespace_service.GovernanceListOptions{ + Unassigned: true, + Page: page, + PageSize: setting.UI.Admin.UserPagingNum, + }) + if err != nil { + ctx.ServerError("ListUnassignedCodespaces", err) + return + } + ctx.Data["Codespaces"] = unassigned.Rows + ctx.Data["CodespaceTotal"] = unassigned.Total + ctx.Data["CodespaceEmptyMessage"] = ctx.Tr("codespace.no_unassigned_codespaces") + ctx.Data["CodespaceActionBase"] = opts.ActionBase + "/unassigned" + ctx.Data["Page"] = context.NewPagination(unassigned.Total, setting.UI.Admin.UserPagingNum, page, 5) + } + ctx.HTML(http.StatusOK, opts.Template) +} + +func renderManagerDetail(ctx *context.Context, opts managerSettingsRenderOptions) { + page := max(ctx.FormInt("page"), 1) + detail, err := codespace_service.GetManagerDetail(ctx, codespace_service.ManagerDetailOptions{ + ManagerSettingsOptions: codespace_service.ManagerSettingsOptions{Scope: opts.Scope, UserID: opts.UserID}, + ManagerID: ctx.PathParamInt64("manager_id"), + Page: page, + PageSize: setting.UI.Admin.UserPagingNum, + }) + if err != nil { + if errors.Is(err, codespace_service.ErrManagerSettingsNotFound) { + ctx.NotFound(nil) + } else { + ctx.ServerError("GetManagerDetail", err) + } + return + } + ctx.Data["Title"] = detail.Manager.Name + ctx.Data[opts.PageFlag] = true + ctx.Data["Manager"] = detail.Manager + ctx.Data["Codespaces"] = detail.Codespaces + ctx.Data["CodespaceTotal"] = detail.Total + ctx.Data["CodespaceEmptyMessage"] = ctx.Tr("codespace.no_bound_codespaces") + ctx.Data["ActionBase"] = opts.ActionBase + ctx.Data["IsSiteManagerSettings"] = opts.Scope == codespace_service.ManagerSettingsScopeSite + ctx.Data["CodespaceActionBase"] = opts.ActionBase + "/" + strconv.FormatInt(detail.Manager.ID, 10) + "/codespaces" + ctx.Data["Page"] = context.NewPagination(detail.Total, setting.UI.Admin.UserPagingNum, page, 5) + ctx.HTML(http.StatusOK, opts.Template) +} + +func handleManagerDelete(ctx *context.Context, opts managerSettingsRenderOptions) { + err := codespace_service.DeleteManager(ctx, codespace_service.DeleteManagerOptions{ + Scope: opts.Scope, + UserID: opts.UserID, + ManagerID: ctx.PathParamInt64("manager_id"), + Confirm: ctx.FormString("confirm") == "delete-manager", + }) + if err != nil { + handleManagerSettingsActionError(ctx, opts.ActionBase+"/"+ctx.PathParam("manager_id"), err) + return + } + ctx.JSONRedirect(opts.ActionBase) +} + +func handleManagerSettingsResetRegistrationToken(ctx *context.Context, opts managerSettingsRenderOptions) { + _, err := codespace_service.ResetRegistrationToken(ctx, codespace_service.ManagerSettingsOptions{ + Scope: opts.Scope, + UserID: opts.UserID, + }) + if err != nil { + handleManagerSettingsActionError(ctx, opts.ActionBase, err) + return + } + ctx.Flash.Success(ctx.Tr("codespace.registration_token_reset")) + ctx.JSONRedirect(opts.ActionBase) +} + +func handleManagerSettingsActionError(ctx *context.Context, redirectTo string, err error) { + switch { + case errors.Is(err, codespace_service.ErrManagerSettingsNotFound): + ctx.NotFound(nil) + case errors.Is(err, codespace_service.ErrManagerSettingsConfirmRequired): + ctx.Flash.Error(ctx.Tr("codespace.error.confirm_required")) + ctx.Redirect(redirectTo, http.StatusSeeOther) + case errors.Is(err, codespace_service.ErrManagerSettingsOwnershipConflict): + ctx.Flash.Error(ctx.Tr("codespace.manager_ownership_conflict")) + ctx.Redirect(redirectTo, http.StatusSeeOther) + default: + ctx.ServerError("CodespaceManagerSettingsAction", err) + } +} diff --git a/routers/web/codespace/settings_permission.go b/routers/web/codespace/settings_permission.go new file mode 100644 index 0000000000000..7986814e55a19 --- /dev/null +++ b/routers/web/codespace/settings_permission.go @@ -0,0 +1,65 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "errors" + "net/http" + + "gitea.dev/models/perm" + "gitea.dev/modules/setting" + "gitea.dev/modules/templates" + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/context" +) + +const tplUserCodespacePermissions templates.TplName = "codespace/user_permissions" + +// UserPermissionSettings renders the current user's remembered repository grants. +func UserPermissionSettings(ctx *context.Context) { + authorizations, err := codespace_service.ListPermissionAuthorizations(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("ListPermissionAuthorizations", err) + return + } + ctx.Data["Title"] = ctx.Tr("codespace.permissions") + ctx.Data["PageIsCodespacePermissions"] = true + ctx.Data["PermissionAuthorizations"] = authorizations + ctx.HTML(http.StatusOK, tplUserCodespacePermissions) +} + +// UserPermissionSettingsPost reduces or revokes a remembered repository grant. +func UserPermissionSettingsPost(ctx *context.Context) { + authorizationID := ctx.FormInt64("authorization_id") + var err error + switch ctx.FormString("action") { + case "revoke": + err = codespace_service.RevokePermissionAuthorization(ctx, ctx.Doer.ID, authorizationID) + case "reduce": + var mode perm.AccessMode + switch ctx.FormString("mode") { + case "read": + mode = perm.AccessModeRead + case "none": + mode = perm.AccessModeNone + default: + err = codespace_service.ErrPermissionReductionInvalid + } + if err == nil { + err = codespace_service.ReducePermissionRepository(ctx, ctx.Doer.ID, authorizationID, ctx.FormInt64("rule_id"), mode) + } + default: + err = codespace_service.ErrPermissionReductionInvalid + } + if err != nil { + if errors.Is(err, codespace_service.ErrPermissionAuthorizationNotFound) { + ctx.NotFound(nil) + return + } + ctx.Flash.Error(ctx.Tr("codespace.error.invalid_request")) + } else { + ctx.Flash.Success(ctx.Tr("codespace.permissions_updated")) + } + ctx.Redirect(setting.AppSubURL+"/user/settings/codespaces/permissions", http.StatusSeeOther) +} diff --git a/routers/web/codespace/settings_secret.go b/routers/web/codespace/settings_secret.go new file mode 100644 index 0000000000000..508bf45365a40 --- /dev/null +++ b/routers/web/codespace/settings_secret.go @@ -0,0 +1,135 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "errors" + "net/http" + "strconv" + + "gitea.dev/modules/setting" + "gitea.dev/modules/templates" + "gitea.dev/modules/util" + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/context" +) + +const tplUserCodespaceSecrets templates.TplName = "codespace/user_secrets" + +// UserSecretSettings renders the current user's Codespace environment secrets. +func UserSecretSettings(ctx *context.Context) { + secrets, err := codespace_service.ListUserSecrets(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("ListUserSecrets", err) + return + } + ctx.Data["Title"] = ctx.Tr("codespace.secrets") + ctx.Data["PageIsCodespaceSecrets"] = true + ctx.Data["CodespaceSecrets"] = secrets + ctx.HTML(http.StatusOK, tplUserCodespaceSecrets) +} + +// UserSecretSettingsPost creates a user-owned Codespace secret. +func UserSecretSettingsPost(ctx *context.Context) { + repoIDs, err := codespaceSecretRepositoryIDsFromForm(ctx) + if err == nil { + err = codespace_service.CreateUserSecret(ctx, ctx.Doer, ctx.FormString("name"), ctx.FormString("value"), ctx.FormBool("all_repositories"), repoIDs) + } + if err != nil { + codespaceSettingsJSONError(ctx, err) + return + } + ctx.Flash.Success(ctx.Tr("codespace.secret_updated")) + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/codespaces/secrets") +} + +// UserSecretSettingsValue replaces one secret value. +func UserSecretSettingsValue(ctx *context.Context) { + err := codespace_service.UpdateUserSecretValue(ctx, ctx.Doer.ID, ctx.PathParamInt64("secret_id"), ctx.FormString("value")) + if err != nil { + codespaceSettingsJSONError(ctx, err) + return + } + ctx.Flash.Success(ctx.Tr("codespace.secret_updated")) + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/codespaces/secrets") +} + +// UserSecretSettingsAccess replaces one secret repository scope. +func UserSecretSettingsAccess(ctx *context.Context) { + repoIDs, err := codespaceSecretRepositoryIDsFromForm(ctx) + if err == nil { + err = codespace_service.UpdateUserSecretRepositoryAccess(ctx, ctx.Doer, ctx.PathParamInt64("secret_id"), ctx.FormBool("all_repositories"), repoIDs) + } + if err != nil { + codespaceSettingsJSONError(ctx, err) + return + } + ctx.Flash.Success(ctx.Tr("codespace.secret_updated")) + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/codespaces/secrets") +} + +// UserSecretSettingsDelete deletes one secret and its selected repository scope. +func UserSecretSettingsDelete(ctx *context.Context) { + err := codespace_service.DeleteUserSecret(ctx, ctx.Doer.ID, ctx.PathParamInt64("secret_id")) + if err != nil { + codespaceSettingsJSONError(ctx, err) + return + } + ctx.Flash.Success(ctx.Tr("codespace.secret_updated")) + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/codespaces/secrets") +} + +// UserSecretRepositorySearch returns repositories the current user can grant to a personal secret. +func UserSecretRepositorySearch(ctx *context.Context) { + repositories, err := codespace_service.SearchWritableSecretRepositories(ctx, ctx.Doer, ctx.FormTrim("q")) + if err != nil { + ctx.ServerError("SearchWritableSecretRepositories", err) + return + } + type repositoryResult struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` + } + result := make([]repositoryResult, 0, len(repositories)) + for _, repo := range repositories { + result = append(result, repositoryResult{ID: repo.ID, FullName: repo.FullName()}) + } + ctx.JSON(http.StatusOK, map[string]any{"data": result}) +} + +func codespaceSecretRepositoryIDsFromForm(ctx *context.Context) ([]int64, error) { + values := ctx.FormStrings("repository_ids") + result := make([]int64, 0, len(values)) + for _, value := range values { + id, err := strconv.ParseInt(value, 10, 64) + if err != nil || id <= 0 { + return nil, util.NewInvalidArgumentErrorf("invalid repository") + } + result = append(result, id) + } + return result, nil +} + +func codespaceSettingsJSONError(ctx *context.Context, err error) { + switch { + case errors.Is(err, codespace_service.ErrUserSecretNotFound): + ctx.JSONErrorNotFound() + case errors.Is(err, util.ErrPermissionDenied): + ctx.JSONError(ctx.Tr("codespace.secret_repository_permission_required")) + case errors.Is(err, codespace_service.ErrUserSecretNameInvalid): + ctx.JSONError(ctx.Tr("codespace.secret_name_invalid")) + case errors.Is(err, codespace_service.ErrUserSecretNameConflict): + ctx.JSONError(ctx.Tr("codespace.secret_name_conflict")) + case errors.Is(err, codespace_service.ErrUserSecretValueInvalid): + ctx.JSONError(ctx.Tr("codespace.secret_value_invalid")) + case errors.Is(err, codespace_service.ErrUserSecretCountLimit): + ctx.JSONError(ctx.Tr("codespace.secret_count_limit")) + case errors.Is(err, codespace_service.ErrUserSecretSizeLimit): + ctx.JSONError(ctx.Tr("codespace.secret_size_limit")) + case errors.Is(err, util.ErrInvalidArgument): + ctx.JSONError(ctx.Tr("codespace.secret_update_failed")) + default: + ctx.ServerError("UpdateCodespaceSettings", err) + } +} diff --git a/routers/web/codespace/view.go b/routers/web/codespace/view.go new file mode 100644 index 0000000000000..d1c859836589b --- /dev/null +++ b/routers/web/codespace/view.go @@ -0,0 +1,177 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "errors" + "net/http" + "strings" + + "gitea.dev/models/organization" + "gitea.dev/modules/setting" + "gitea.dev/modules/templates" + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/context" +) + +type openEndpointModalData struct { + Label string + Access string + OpenPath string +} + +const ( + tplCodespaceList templates.TplName = "codespace/list" + tplCodespaceDetail templates.TplName = "codespace/detail" + tplCodespaceState templates.TplName = "codespace/state" +) + +// List renders the current user's Codespaces. +func List(ctx *context.Context) { + if ctx.Doer == nil { + ctx.NotFound(nil) + return + } + orgs, err := organization.GetUserOrgsList(ctx, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserOrgsList", err) + return + } + ctx.Data["Orgs"] = orgs + ctx.Data["ContextUser"] = ctx.Doer + + ownerName := strings.TrimSpace(ctx.FormString("owner")) + var repoOwnerID int64 + if ownerName != "" { + var selectedOrg *organization.Organization + for _, org := range orgs { + if strings.EqualFold(org.Name, ownerName) { + selectedOrg = org + break + } + } + if selectedOrg == nil { + ctx.NotFound(nil) + return + } + ownerName = selectedOrg.Name + repoOwnerID = selectedOrg.ID + ctx.Data["ContextUser"] = selectedOrg.AsUser() + } + + page := max(ctx.FormInt("page"), 1) + pageSize := setting.UI.User.RepoPagingNum + result, err := codespace_service.ListCreatorCodespaces(ctx, codespace_service.CreatorListOptions{ + UserID: ctx.Doer.ID, + RepoOwnerID: repoOwnerID, + Page: page, + PageSize: pageSize, + }) + if err != nil { + ctx.ServerError("ListCreatorCodespaces", err) + return + } + if page > 1 && len(result.Rows) == 0 && result.Total > 0 { + lastPage := int((result.Total + int64(pageSize) - 1) / int64(pageSize)) + ctx.Redirect(setting.AppSubURL+codespaceListPath(ownerName, lastPage), http.StatusSeeOther) + return + } + + ctx.Data["Title"] = ctx.Tr("codespace.title") + ctx.Data["PageIsCodespaces"] = true + ctx.Data["Codespaces"] = result.Rows + ctx.Data["CodespaceOwner"] = ownerName + ctx.Data["CodespaceListReturnTo"] = setting.AppSubURL + codespaceListPath(ownerName, page) + pager := context.NewPagination(result.Total, pageSize, page, 5) + pager.AddParamFromRequest(ctx.Req) + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplCodespaceList) +} + +// Detail renders the current user's single Codespace page. +func Detail(ctx *context.Context) { + view, ok := loadCreatorDetail(ctx) + if !ok { + return + } + ctx.Data["Title"] = "Codespace" + ctx.Data["PageIsCodespaces"] = true + ctx.Data["Codespace"] = view + setCreatorDetailTab(ctx, view) + if endpointID := strings.TrimSpace(ctx.FormString("open_endpoint")); endpointID != "" { + var endpoint *codespace_service.CreatorEndpointView + if endpointID == "workspace" { + endpoint = view.Workspace + } else { + for i := range view.Endpoints { + if view.Endpoints[i].EndpointID == endpointID { + endpoint = &view.Endpoints[i] + break + } + } + } + if endpoint != nil && endpoint.CanOpen { + label := endpoint.Label + if endpoint.EndpointID == "workspace" { + label = string(ctx.Tr("codespace.workspace")) + } + access := string(ctx.Tr("codespace.authenticated_endpoint")) + if endpoint.Public { + access = string(ctx.Tr("codespace.public_endpoint")) + } + ctx.Data["OpenEndpointModal"] = &openEndpointModalData{ + Label: label, + Access: access, + OpenPath: endpoint.OpenPath, + } + } else { + ctx.Data["OpenEndpointError"] = true + } + } + ctx.RespHeader().Set("Cache-Control", "no-store") + ctx.HTML(http.StatusOK, tplCodespaceDetail) +} + +// State renders the live state fragment for a single Codespace. +func State(ctx *context.Context) { + view, ok := loadCreatorDetail(ctx) + if !ok { + return + } + ctx.Data["Codespace"] = view + setCreatorDetailTab(ctx, view) + ctx.RespHeader().Set("Cache-Control", "no-store") + ctx.HTML(http.StatusOK, tplCodespaceState) +} + +func setCreatorDetailTab(ctx *context.Context, view *codespace_service.CreatorCodespaceView) { + tab := strings.TrimSpace(ctx.FormString("tab")) + explicit := tab == codespace_service.DetailModeOverview || tab == codespace_service.DetailModeLogs + if !explicit { + tab = view.DetailMode + } + ctx.Data["CodespaceTab"] = tab + ctx.Data["CodespaceTabExplicit"] = explicit +} + +func loadCreatorDetail(ctx *context.Context) (*codespace_service.CreatorCodespaceView, bool) { + if ctx.Doer == nil { + ctx.NotFound(nil) + return nil, false + } + view, err := codespace_service.GetCreatorCodespace(ctx, codespace_service.CreatorDetailOptions{ + UserID: ctx.Doer.ID, + CodespaceUUID: ctx.PathParam("uuid"), + }) + if err != nil { + switch { + case errors.Is(err, codespace_service.ErrViewNotFound), errors.Is(err, codespace_service.ErrViewPermissionDenied): + ctx.NotFound(nil) + default: + ctx.ServerError("GetCreatorCodespace", err) + } + return nil, false + } + return view, true +} diff --git a/routers/web/codespace/view_test.go b/routers/web/codespace/view_test.go new file mode 100644 index 0000000000000..60ede46967e7c --- /dev/null +++ b/routers/web/codespace/view_test.go @@ -0,0 +1,201 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "net/http" + "strings" + "testing" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + "gitea.dev/modules/templates" + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/contexttest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListRendersCreatorCodespaces(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + codespaceUUID := "22222222-2222-4222-8222-222222222222" + insertWebViewCodespace(t, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + }) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces", contexttest.MockContextOption{Render: templates.PageRenderer()}) + contexttest.LoadUser(t, ctx, 1) + List(ctx) + + require.Equal(t, http.StatusOK, resp.Code) + rows, ok := ctx.Data["Codespaces"].([]*codespace_service.CreatorCodespaceView) + require.True(t, ok) + require.Len(t, rows, 1) + assert.Equal(t, codespaceUUID, rows[0].UUID) + assert.Contains(t, resp.Body.String(), codespaceUUID) + assert.Contains(t, resp.Body.String(), "context-user-switch") + assert.NotNil(t, ctx.Data["Page"]) +} + +func TestListFiltersCurrentCreatorByOrganizationRepositories(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + codespaceUUID := "39393939-3939-4939-8939-393939393939" + insertWebViewCodespace(t, &codespace_model.Codespace{UUID: codespaceUUID, Status: codespace_model.StatusStopped}) + _, err := db.GetEngine(t.Context()).ID(codespaceUUID).Cols("user_id", "repo_id").Update(&codespace_model.Codespace{UserID: 2, RepoID: 3}) + require.NoError(t, err) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces?owner=org3", contexttest.MockContextOption{Render: templates.PageRenderer()}) + contexttest.LoadUser(t, ctx, 2) + List(ctx) + + require.Equal(t, http.StatusOK, resp.Code) + rows, ok := ctx.Data["Codespaces"].([]*codespace_service.CreatorCodespaceView) + require.True(t, ok) + require.Len(t, rows, 1) + assert.Equal(t, codespaceUUID, rows[0].UUID) + assert.Equal(t, "org3", ctx.Data["CodespaceOwner"]) +} + +func TestDetailRendersCreatorCodespaceNoStore(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + codespaceUUID := "24242424-2424-4424-8424-242424242424" + insertWebViewCodespace(t, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + }) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID, contexttest.MockContextOption{Render: templates.PageRenderer()}) + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("uuid", codespaceUUID) + Detail(ctx) + + require.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "no-store", resp.Header().Get("Cache-Control")) + view, ok := ctx.Data["Codespace"].(*codespace_service.CreatorCodespaceView) + require.True(t, ok) + assert.Equal(t, codespaceUUID, view.UUID) + assert.Contains(t, resp.Body.String(), "data-refresh-after-ms=\"2000\"") + assert.Contains(t, resp.Body.String(), "id=\"codespace-settings-modal\"") + assert.Contains(t, resp.Body.String(), "name=\"timeout_unit\"") + assert.Contains(t, resp.Body.String(), "data-log-next-offset=\"0\"") + assert.Equal(t, codespace_service.DetailModeLogs, ctx.Data["CodespaceTab"]) + assert.False(t, ctx.Data["CodespaceTabExplicit"].(bool)) + assert.NotContains(t, resp.Body.String(), "Codespace "+codespaceUUID[:8]) +} + +func TestDetailPreservesExplicitOverviewTab(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + codespaceUUID := "30303030-3030-4030-8030-303030303030" + insertWebViewCodespace(t, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + }) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"?tab=overview", contexttest.MockContextOption{Render: templates.PageRenderer()}) + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("uuid", codespaceUUID) + Detail(ctx) + + require.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, codespace_service.DetailModeOverview, ctx.Data["CodespaceTab"]) + assert.True(t, ctx.Data["CodespaceTabExplicit"].(bool)) + assert.NotContains(t, resp.Body.String(), "data-log-next-offset=\"0\"") +} + +func TestDetailOpensGatewayRecoveryModal(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertWebOpenManager(t, "https://gateway.example.com") + codespaceUUID := "27272727-2727-4727-8727-272727272727" + insertWebOpenCodespace(t, manager.ID, codespaceUUID, 94) + require.NoError(t, codespace_service.ReportRuntimeMetadata(t.Context(), manager, codespace_service.ReportRuntimeMetadataOptions{ + CodespaceUUID: codespaceUUID, + Metadata: webOpenRuntimeMetadata(t, 94, []map[string]any{{"endpoint_id": "app-3000", "label": "App", "public": false}}), + MetadataGeneration: 1, + })) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"?open_endpoint=app-3000", contexttest.MockContextOption{Render: templates.PageRenderer()}) + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("uuid", codespaceUUID) + Detail(ctx) + + require.Equal(t, http.StatusOK, resp.Code) + modal, ok := ctx.Data["OpenEndpointModal"].(*openEndpointModalData) + require.True(t, ok) + assert.Equal(t, "App", modal.Label) + assert.Equal(t, "codespace.authenticated_endpoint", modal.Access) + assert.Equal(t, "/-/codespaces/"+codespaceUUID+"/open/app-3000", modal.OpenPath) + assert.Contains(t, resp.Body.String(), `data-codespace-auto-open="true"`) + assert.Contains(t, resp.Body.String(), `target="_self"`) + assert.Equal(t, 1, strings.Count(resp.Body.String(), `data-modal-form.url="/-/codespaces/`+codespaceUUID+`/open"`)) +} + +func TestStateRendersFragmentNoStore(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + codespaceUUID := "25252525-2525-4525-8525-252525252525" + insertWebViewCodespace(t, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + }) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/state", contexttest.MockContextOption{Render: templates.PageRenderer()}) + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("uuid", codespaceUUID) + State(ctx) + + require.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "no-store", resp.Header().Get("Cache-Control")) + assert.Contains(t, resp.Body.String(), "id=\"codespace-live-state\"") + assert.Contains(t, resp.Body.String(), "data-state-url=\"/-/codespaces/"+codespaceUUID+"/state?tab=logs\"") + assert.Contains(t, resp.Body.String(), "data-refresh-after-ms=\"2000\"") + assert.Contains(t, resp.Body.String(), "data-auto-stop-configurable=\"false\"") + assert.Contains(t, resp.Body.String(), "data-detail-mode=\"logs\"") +} + +func TestDetailRejectsOtherCreator(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + codespaceUUID := "26262626-2626-4626-8626-262626262626" + insertWebViewCodespace(t, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusStopped, + }) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID) + contexttest.LoadUser(t, ctx, 2) + ctx.SetPathParam("uuid", codespaceUUID) + Detail(ctx) + + require.Equal(t, http.StatusNotFound, resp.Code) +} + +func insertWebViewCodespace(t *testing.T, codespace *codespace_model.Codespace) { + t.Helper() + codespace.UserID = 1 + codespace.RepoID = 2 + codespace.RefType = "branch" + codespace.RefName = "main" + codespace.EnvironmentTag = "default" + codespace.CommitSHA = "0123456789abcdef0123456789abcdef01234567" + codespace.AutoStopMode = codespace_model.AutoStopModeDefault + codespace.CreatedUnix = 1 + codespace.UpdatedUnix = 1 + require.NoError(t, db.Insert(t.Context(), codespace)) +} diff --git a/routers/web/feed/render.go b/routers/web/feed/render.go index d06aa22e64c4e..e0f0ff146843b 100644 --- a/routers/web/feed/render.go +++ b/routers/web/feed/render.go @@ -5,6 +5,7 @@ package feed import ( auth_model "gitea.dev/models/auth" + "gitea.dev/models/unit" "gitea.dev/services/context" ) @@ -12,7 +13,7 @@ import ( // feed serves private repository content, mirroring checkDownloadTokenScope for // downloads. Returns false (and writes the response) when the token is denied. func checkRepoFeedTokenScope(ctx *context.Context) bool { - context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read) + context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, unit.TypeCode, auth_model.Read) return !ctx.Written() } diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index f948c763d55cb..302e9d11212bf 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -422,6 +422,9 @@ func Diff(ctx *context.Context) { if pr != nil { ctx.Data["MergedPRIssueNumber"] = pr.Index } + if !fileOnly && ctx.Data["PageIsWiki"] == nil { + prepareCodespaceSourcePanel(ctx, "commit", commitID, "", base.ShortSha(commitID)) + } ctx.HTML(http.StatusOK, tplCommitPage) } diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go index ed6de00092ebe..a62589dd44d48 100644 --- a/routers/web/repo/download.go +++ b/routers/web/repo/download.go @@ -9,6 +9,7 @@ import ( auth_model "gitea.dev/models/auth" git_model "gitea.dev/models/git" + "gitea.dev/models/unit" "gitea.dev/modules/git" "gitea.dev/modules/httpcache" "gitea.dev/modules/httplib" @@ -20,7 +21,7 @@ import ( ) func checkDownloadTokenScope(ctx *context.Context) bool { - context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read) + context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, unit.TypeCode, auth_model.Read) return !ctx.Written() } diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index da8c09f919a19..ab3589a97b125 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -158,7 +158,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { return nil } - context.CheckRepoScopedToken(ctx, repo, auth_model.GetScopeLevelFromAccessMode(accessMode)) + context.CheckRepoScopedToken(ctx, repo, unit.TypeCode, auth_model.GetScopeLevelFromAccessMode(accessMode)) if ctx.Written() { return nil } diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index 84c501c017a79..92acdc9a12b9a 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -396,6 +396,9 @@ func ViewIssue(ctx *context.Context) { } else { ctx.Data["DisableStatusChange"] = prViewInfo.IsPullRequestBroken && issue.IsClosed } + if !issue.IsClosed && !prViewInfo.IsPullRequestBroken { + prepareCodespaceSourcePanel(ctx, "pull", strconv.FormatInt(issue.Index, 10), issue.PullRequest.GetGitHeadRefName(), fmt.Sprintf("#%d", issue.Index)) + } } ctx.Data["Reference"] = issue.Ref diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 70e6e99d0ef93..9d15cec810ae1 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -656,6 +656,9 @@ func ViewPullCommits(ctx *context.Context) { ctx.NotFound(nil) return } + if !issue.IsClosed && !prViewInfo.IsPullRequestBroken { + prepareCodespaceSourcePanel(ctx, "pull", strconv.FormatInt(issue.Index, 10), issue.PullRequest.GetGitHeadRefName(), fmt.Sprintf("#%d", issue.Index)) + } commits, err := processGitCommits(ctx, prCompareInfo.Commits) if err != nil { @@ -710,6 +713,9 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) { ctx.NotFound(nil) return } + if !issue.IsClosed && !prViewInfo.IsPullRequestBroken { + prepareCodespaceSourcePanel(ctx, "pull", strconv.FormatInt(issue.Index, 10), issue.PullRequest.GetGitHeadRefName(), fmt.Sprintf("#%d", issue.Index)) + } headCommitID := prCompareInfo.HeadCommitID isSingleCommit := beforeCommitID == "" && afterCommitID != "" diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index fb3e27294e757..f18072eba7780 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -26,6 +26,7 @@ import ( "gitea.dev/modules/svg" "gitea.dev/modules/util" "gitea.dev/routers/web/feed" + codespace_service "gitea.dev/services/codespace" "gitea.dev/services/context" repo_service "gitea.dev/services/repository" ) @@ -91,6 +92,64 @@ func prepareClonePanel(ctx *context.Context) { // in the future, it's better to use something like "/archive/branch/the-name.zip", "/archive/tag/the-name.zip" */}} ctx.Data["DownloadArchiveLinkPrefix"] = ctx.Repo.RepoLink + "/archive/" + util.PathEscapeSegments(ctx.Repo.RefFullName.ShortName()) } + prepareClonePanelCodespaces(ctx) +} + +func prepareClonePanelCodespaces(ctx *context.Context) { + if !setting.Codespace.Enabled || ctx.Doer == nil || ctx.Repo.Repository == nil { + return + } + result, err := codespace_service.ListCreatorCodespaces(ctx, codespace_service.CreatorListOptions{ + UserID: ctx.Doer.ID, + RepoID: ctx.Repo.Repository.ID, + Limit: 3, + }) + if err != nil { + log.Error("ListCreatorCodespaces: %v", err) + return + } + refType := string(ctx.Repo.RefFullName.RefType()) + refName := ctx.Repo.RefFullName.ShortName() + if refType == string(git.RefTypeCommit) && ctx.Repo.CommitID != "" { + refName = ctx.Repo.CommitID + } + if refType == "" { + refType = string(git.RefTypeBranch) + refName = ctx.Repo.Repository.DefaultBranch + } + ctx.Data["ClonePanelCodespacesEnabled"] = true + ctx.Data["ClonePanelCodespaces"] = result.Rows + ctx.Data["ClonePanelCodespaceRefType"] = refType + ctx.Data["ClonePanelCodespaceRefName"] = refName + ctx.Data["ClonePanelCodespaceCreateURL"] = ctx.Repo.RepoLink + "/codespaces/new" +} + +func prepareCodespaceSourcePanel(ctx *context.Context, refType, requestRefName, storedRefName, refLabel string) { + if !setting.Codespace.Enabled || ctx.Doer == nil || ctx.Repo.Repository == nil || ctx.Repo.Repository.IsArchived || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() || !ctx.Repo.Permission.CanRead(unit_model.TypeCode) { + return + } + listOptions := codespace_service.CreatorListOptions{ + UserID: ctx.Doer.ID, + RepoID: ctx.Repo.Repository.ID, + Limit: 3, + } + if refType == string(git.RefTypeCommit) { + listOptions.CommitSHA = requestRefName + } else { + listOptions.RefType = refType + listOptions.RefName = storedRefName + } + result, err := codespace_service.ListCreatorCodespaces(ctx, listOptions) + if err != nil { + log.Error("ListCreatorCodespaces: %v", err) + return + } + ctx.Data["CodespaceSourcePanelEnabled"] = true + ctx.Data["CodespaceSourcePanelRows"] = result.Rows + ctx.Data["CodespaceSourceRefType"] = refType + ctx.Data["CodespaceSourceRefName"] = requestRefName + ctx.Data["CodespaceSourceRefLabel"] = refLabel + ctx.Data["CodespaceSourceCreateURL"] = ctx.Repo.RepoLink + "/codespaces/new" } func prepareHomeSidebarCitationFile(entry *git.TreeEntry) func(ctx *context.Context) { @@ -395,7 +454,7 @@ func Home(ctx *context.Context) { // a scoped or public-only API token authenticating this web request must still satisfy // the repository read scope before private repo content is served - context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read) + context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, unit_model.TypeCode, auth_model.Read) if ctx.Written() { return } diff --git a/routers/web/user/home.go b/routers/web/user/home.go index 75b51151f3cd0..aa1522c02cfac 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -645,7 +645,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // ShowSSHKeys output all the ssh keys of user by uid func ShowSSHKeys(ctx *context.Context) { keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ - OwnerID: ctx.ContextUser.ID, + OwnerID: ctx.ContextUser.ID, + KeyTypes: []asymkey_model.KeyType{asymkey_model.KeyTypeUser}, }) if err != nil { ctx.ServerError("ListPublicKeys", err) @@ -656,9 +657,6 @@ func ShowSSHKeys(ctx *context.Context) { // "authorized_keys" file format: "#" followed by comment line per key buf.WriteString("# Gitea isn't a key server. The keys are exported as the user uploaded and might not have been fully verified.\n") for i := range keys { - if keys[i].Type == asymkey_model.KeyTypePrincipal { - continue // SSH principal keys are not for signing or authentication - } buf.WriteString(keys[i].OmitEmail()) buf.WriteString("\n") } diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go index 99323261bea24..3d32732ee8f35 100644 --- a/routers/web/user/setting/keys.go +++ b/routers/web/user/setting/keys.go @@ -291,8 +291,8 @@ func DeleteKey(ctx *context.Context) { func loadKeysData(ctx *context.Context) { keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ - OwnerID: ctx.Doer.ID, - NotKeytype: asymkey_model.KeyTypePrincipal, + OwnerID: ctx.Doer.ID, + KeyTypes: []asymkey_model.KeyType{asymkey_model.KeyTypeUser}, }) if err != nil { ctx.ServerError("ListPublicKeys", err) diff --git a/routers/web/web.go b/routers/web/web.go index 99ffc06756c2d..68481b7a2f80d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -28,6 +28,7 @@ import ( "gitea.dev/routers/common" "gitea.dev/routers/web/admin" "gitea.dev/routers/web/auth" + web_codespace "gitea.dev/routers/web/codespace" "gitea.dev/routers/web/devtest" "gitea.dev/routers/web/explore" "gitea.dev/routers/web/feed" @@ -93,14 +94,16 @@ func optionsCorsHandler() func(next http.Handler) http.Handler { } type AuthMiddleware struct { - AllowOAuth2 types.PreMiddlewareProvider - AllowBasic types.PreMiddlewareProvider - MiddlewareHandler func(*context.Context) + AllowOAuth2 types.PreMiddlewareProvider + AllowBasic types.PreMiddlewareProvider + AllowCodespaceToken types.PreMiddlewareProvider + MiddlewareHandler func(*context.Context) } func newWebAuthMiddleware() *AuthMiddleware { type keyAllowOAuth2 struct{} type keyAllowBasic struct{} + type keyAllowCodespaceToken struct{} webAuth := &AuthMiddleware{} middlewareSetContextValue := func(key, val any) types.PreMiddlewareProvider { @@ -115,11 +118,14 @@ func newWebAuthMiddleware() *AuthMiddleware { webAuth.AllowBasic = middlewareSetContextValue(keyAllowBasic{}, true) webAuth.AllowOAuth2 = middlewareSetContextValue(keyAllowOAuth2{}, true) + webAuth.AllowCodespaceToken = middlewareSetContextValue(keyAllowCodespaceToken{}, true) enableSSPI := setting.IsWindows && auth_model.IsSSPIEnabled(graceful.GetManager().ShutdownContext()) webAuth.MiddlewareHandler = func(ctx *context.Context) { allowBasic := ctx.GetContextValue(keyAllowBasic{}) == true allowOAuth2 := ctx.GetContextValue(keyAllowOAuth2{}) == true + allowCodespaceToken := ctx.GetContextValue(keyAllowCodespaceToken{}) == true + auth_service.SetCodespaceTokenAuthAllowed(ctx.Req.Context(), allowCodespaceToken) group := auth_service.NewGroup() @@ -131,6 +137,11 @@ func newWebAuthMiddleware() *AuthMiddleware { if allowBasic { group.Add(&auth_service.Basic{}) } + if allowCodespaceToken { + group.Add(&auth_service.CodespaceToken{}) + } else { + group.Add(&auth_service.CodespaceToken{RejectValid: true}) + } // Sessionless means the route's auth can be done without web ui, then it doesn't need to create a session // For example: accessing git via http, access rss feeds, downloading attachments, etc @@ -151,6 +162,10 @@ func newWebAuthMiddleware() *AuthMiddleware { ar, err := common.AuthShared(ctx.Base, ctx.Session, group) if err != nil { + if auth_service.IsCodespaceTokenForbidden(err) { + ctx.HTTPError(http.StatusForbidden, "Codespace token is not allowed for this request") + return + } log.Error("Failed to verify user: %v", err) ctx.HTTPError(http.StatusUnauthorized, "Failed to authenticate user") return @@ -532,6 +547,20 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/-/web-banner/dismiss", misc.WebBannerDismiss) m.Get("/-/web-theme/list", misc.WebThemeList) m.Post("/-/web-theme/apply", optSignIn, misc.WebThemeApply) + m.Get("/-/codespaces", reqSignIn, web_codespace.List) + m.Group("/-/codespaces/{uuid}", func() { + m.Get("", web_codespace.Detail) + m.Get("/state", web_codespace.State) + m.Get("/logs", web_codespace.Logs) + m.Get("/logs/download", web_codespace.DownloadLogs) + m.Post("/stop", web_codespace.Stop) + m.Post("/resume", web_codespace.Resume) + m.Post("/delete", web_codespace.Delete) + m.Post("/continue", web_codespace.Continue) + m.Post("/auto-stop", web_codespace.AutoStop) + m.Post("/open", web_codespace.Open) + m.Post("/open/{endpoint_id}", web_codespace.OpenEndpoint) + }, reqSignIn) m.Group("/explore", func() { m.Get("", func(ctx *context.Context) { @@ -715,6 +744,16 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { addSettingsScopedWorkflowsRoutes() }, actions.MustEnableActions) + m.Get("/codespaces/managers", web_codespace.UserSettings) + m.Post("/codespaces/managers/reset_registration_token", web_codespace.UserSettingsResetRegistrationToken) + m.Get("/codespaces/managers/{manager_id}", web_codespace.UserManager) + m.Post("/codespaces/managers/{manager_id}/delete", web_codespace.UserManagerDelete) + m.Combo("/codespaces/permissions").Get(web_codespace.UserPermissionSettings).Post(web_codespace.UserPermissionSettingsPost) + m.Combo("/codespaces/secrets").Get(web_codespace.UserSecretSettings).Post(web_codespace.UserSecretSettingsPost) + m.Get("/codespaces/secrets/repositories", web_codespace.UserSecretRepositorySearch) + m.Post("/codespaces/secrets/{secret_id}/value", web_codespace.UserSecretSettingsValue) + m.Post("/codespaces/secrets/{secret_id}/access", web_codespace.UserSecretSettingsAccess) + m.Post("/codespaces/secrets/{secret_id}/delete", web_codespace.UserSecretSettingsDelete) m.Get("/organization", user_setting.Organization) m.Get("/repos", user_setting.Repos) m.Post("/repos/unadopted", user_setting.AdoptOrDeleteRepository) @@ -828,6 +867,19 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/delete", admin.DeleteRepo) }) + m.Group("/codespaces", func() { + m.Get("/managers", web_codespace.AdminManagers) + m.Post("/managers/reset_registration_token", web_codespace.AdminManagersResetRegistrationToken) + m.Get("/managers/{manager_id}", web_codespace.AdminManager) + m.Post("/managers/{manager_id}/delete", web_codespace.AdminManagerDelete) + m.Post("/managers/{manager_id}/codespaces/{uuid}/stop", web_codespace.AdminStop) + m.Post("/managers/{manager_id}/codespaces/{uuid}/delete", web_codespace.AdminDelete) + m.Post("/managers/{manager_id}/codespaces/{uuid}/force-delete", web_codespace.AdminForceDelete) + m.Post("/managers/unassigned/{uuid}/stop", web_codespace.AdminStop) + m.Post("/managers/unassigned/{uuid}/delete", web_codespace.AdminDelete) + m.Post("/managers/unassigned/{uuid}/force-delete", web_codespace.AdminForceDelete) + }) + m.Group("/packages", func() { m.Get("", admin.Packages) m.Post("/delete", admin.DeletePackageVersion) @@ -1268,6 +1320,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { // user/org home, including rss feeds like "/{username}/{reponame}.rss" m.Get("/{username}/{reponame}", optSignIn, webAuth.AllowBasic, context.RepoAssignment, context.RepoRefByType(git.RefTypeBranch), repo.SetEditorconfigIfExists, repo.Home) + m.Get("/{username}/{reponame}/codespaces", reqSignIn, context.RepoAssignment, reqUnitCodeReader, web_codespace.RepositoryRedirect) + m.Get("/{username}/{reponame}/codespaces/new", reqSignIn, context.RepoAssignment, reqUnitCodeReader, web_codespace.New) + m.Post("/{username}/{reponame}/codespaces", reqSignIn, context.RepoAssignment, reqUnitCodeReader, web_codespace.Create) m.Post("/{username}/{reponame}/markup", optSignIn, context.RepoAssignment, reqUnitsWithMarkdown, web.Bind(structs.MarkupOption{}), misc.Markup) @@ -1749,12 +1804,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { // git lfs uses its own jwt key, and it handles the token & auth by itself, it conflicts with the general "OAuth2" auth method // pattern: "/{username}/{reponame}/{lfs-paths}": git-lfs support, see also addOwnerRepoGitHTTPRouters - common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, webAuth.AllowBasic, repo.CorsHandler(), optSignInFromAnyOrigin) + common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, webAuth.AllowBasic, webAuth.AllowCodespaceToken, repo.CorsHandler(), optSignInFromAnyOrigin) // Some users want to use "web-based git client" to access Gitea's repositories, // so the CORS handler and OPTIONS method are used. // pattern: "/{username}/{reponame}/{git-paths}": git http support - addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb()) + addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, webAuth.AllowCodespaceToken, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb()) m.Group("/notifications", func() { m.Get("", user.Notifications) diff --git a/services/asymkey/commit.go b/services/asymkey/commit.go index c10fe9d6983bf..0a832e32b3ab5 100644 --- a/services/asymkey/commit.go +++ b/services/asymkey/commit.go @@ -368,8 +368,8 @@ func parseCommitWithSSHSignature(ctx context.Context, c *git.Commit, committerUs // Now try to associate the signature with the committer, if present if committerUser.ID != 0 { keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ - OwnerID: committerUser.ID, - NotKeytype: asymkey_model.KeyTypePrincipal, + OwnerID: committerUser.ID, + KeyTypes: []asymkey_model.KeyType{asymkey_model.KeyTypeUser}, }) if err != nil { // Skipping failed to get ssh keys of user log.Error("ListPublicKeys: %v", err) diff --git a/services/asymkey/sign.go b/services/asymkey/sign.go index b7c47e3ab53f8..643272b9a22ac 100644 --- a/services/asymkey/sign.go +++ b/services/asymkey/sign.go @@ -77,8 +77,8 @@ func userHasPubkeysGPG(ctx context.Context, userID int64) (bool, error) { func userHasPubkeysSSH(ctx context.Context, userID int64) (bool, error) { return db.Exist[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ - OwnerID: userID, - NotKeytype: asymkey_model.KeyTypePrincipal, + OwnerID: userID, + KeyTypes: []asymkey_model.KeyType{asymkey_model.KeyTypeUser}, }.ToConds()) } diff --git a/services/asymkey/sign_test.go b/services/asymkey/sign_test.go index 06480e8cb1d95..8e24cc1d4a6f3 100644 --- a/services/asymkey/sign_test.go +++ b/services/asymkey/sign_test.go @@ -6,6 +6,9 @@ package asymkey import ( "testing" + asymkey_model "gitea.dev/models/asymkey" + "gitea.dev/models/db" + "gitea.dev/models/perm" "gitea.dev/models/unittest" "github.com/stretchr/testify/assert" @@ -36,4 +39,18 @@ func TestUserHasPubkeys(t *testing.T) { t.Run("DenyUserWithNoKeys", func(t *testing.T) { test(t, 1, false, false) // no pubkey }) + t.Run("DenyUserWithOnlyCodespaceSSHKey", func(t *testing.T) { + const keyContent = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH6Y4idVaW3E+bLw1uqoAfJD7o5Siu+HqS51E9oQLPE9" + fingerprint, err := asymkey_model.CalcFingerprint(keyContent) + require.NoError(t, err) + require.NoError(t, db.Insert(t.Context(), &asymkey_model.PublicKey{ + OwnerID: 1, + Name: "codespace-test", + Fingerprint: fingerprint, + Content: keyContent, + Mode: perm.AccessModeWrite, + Type: asymkey_model.KeyTypeCodespace, + })) + test(t, 1, false, false) + }) } diff --git a/services/asymkey/ssh_key.go b/services/asymkey/ssh_key.go index fc97a2e2aa164..3bf18b8980fa4 100644 --- a/services/asymkey/ssh_key.go +++ b/services/asymkey/ssh_key.go @@ -26,6 +26,13 @@ func DeletePublicKey(ctx context.Context, doer *user_model.User, id int64) (err Note: "public", } } + if key.Type == asymkey_model.KeyTypeCodespace { + return asymkey_model.ErrKeyAccessDenied{ + UserID: doer.ID, + KeyID: key.ID, + Note: "codespace", + } + } if _, err = db.DeleteByID[asymkey_model.PublicKey](ctx, id); err != nil { return err diff --git a/services/auth/basic.go b/services/auth/basic.go index 26883a3746479..e74eefd315a75 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -16,6 +16,7 @@ import ( "gitea.dev/modules/setting" "gitea.dev/modules/timeutil" "gitea.dev/modules/util" + codespace_service "gitea.dev/services/codespace" ) // Ensure the struct implements the interface. @@ -69,6 +70,19 @@ func (b *Basic) parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, // VerifyAuthToken only the access token provided as parameter, used by other auth methods that want to reuse access token verification logic func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore, authToken string) (*user_model.User, error) { + if codespace_service.IsGiteaTokenPlaintext(authToken) && !codespaceTokenAuthAllowed(req.Context()) { + return nil, errors.Join(ErrAuthMethodTerminal, ErrCodespaceTokenForbidden) + } + codespaceToken, err := codespace_service.ResolveGiteaToken(req.Context(), authToken) + if err != nil { + if authErr := codespaceTokenAuthError(err); authErr != nil { + return nil, authErr + } + } else { + storeCodespaceTokenAuth(store, codespaceToken) + return codespaceToken.User, nil + } + // get oauth2 token's user's ID accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken) if uid != 0 { @@ -195,6 +209,8 @@ func GetAccessScope(store DataStore) auth_model.AccessTokenScope { fallthrough case BasicMethodName, AccessTokenMethodName: return auth_model.AccessTokenScopeAll + case CodespaceTokenMethodName: + return auth_model.AccessTokenScope(codespace_service.GiteaTokenScope) case ActionTokenMethodName: fallthrough default: diff --git a/services/auth/codespace.go b/services/auth/codespace.go new file mode 100644 index 0000000000000..013acd506a20d --- /dev/null +++ b/services/auth/codespace.go @@ -0,0 +1,120 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "errors" + "net/http" + + codespace_model "gitea.dev/models/codespace" + user_model "gitea.dev/models/user" + "gitea.dev/modules/auth/httpauth" + "gitea.dev/modules/reqctx" + codespace_service "gitea.dev/services/codespace" +) + +// CodespaceTokenMethodName is the constant name of the Codespace Token authentication method. +const CodespaceTokenMethodName = "codespace_token" + +// ErrAuthMethodTerminal stops the auth group from trying fallback methods. +var ErrAuthMethodTerminal = errors.New("auth method rejected credential") + +// ErrCodespaceTokenForbidden is returned when a valid Codespace Token cannot be used for this request. +var ErrCodespaceTokenForbidden = errors.New("codespace token forbidden") + +// IsCodespaceTokenForbidden reports whether an auth error should be returned as authorization failure. +func IsCodespaceTokenForbidden(err error) bool { + return errors.Is(err, ErrCodespaceTokenForbidden) +} + +func codespaceTokenAuthError(err error) error { + if err == nil || errors.Is(err, codespace_service.ErrResolveGiteaTokenUnmatched) { + return nil + } + if errors.Is(err, codespace_service.ErrResolveGiteaTokenForbidden) { + return errors.Join(ErrAuthMethodTerminal, ErrCodespaceTokenForbidden) + } + return errors.Join(ErrAuthMethodTerminal, err) +} + +// CodespaceToken recognizes Codespace Tokens on routes that do not otherwise allow token auth. +type CodespaceToken struct { + RejectValid bool +} + +type ( + codespaceTokenAuthAllowedKey struct{} +) + +// SetCodespaceTokenAuthAllowed records whether the current Web route may authenticate Codespace Tokens. +func SetCodespaceTokenAuthAllowed(ctx context.Context, allowed bool) { + if store := reqctx.GetRequestDataStore(ctx); store != nil { + store.SetContextValue(codespaceTokenAuthAllowedKey{}, allowed) + } +} + +func codespaceTokenAuthAllowed(ctx context.Context) bool { + store := reqctx.GetRequestDataStore(ctx) + return store == nil || store.GetContextValue(codespaceTokenAuthAllowedKey{}) != false +} + +func (m *CodespaceToken) Name() string { + return CodespaceTokenMethodName +} + +func (m *CodespaceToken) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) { + token := parseCodespaceAuthHeaderToken(req) + if token == "" { + return nil, nil //nolint:nilnil // the auth method is not applicable + } + codespaceToken, err := codespace_service.ResolveGiteaToken(req.Context(), token) + if err != nil { + return nil, codespaceTokenAuthError(err) + } + if m.RejectValid { + return nil, errors.Join(ErrAuthMethodTerminal, ErrCodespaceTokenForbidden) + } + storeCodespaceTokenAuth(store, codespaceToken) + return codespaceToken.User, nil +} + +func parseCodespaceAuthHeaderToken(req *http.Request) string { + authHeader := req.Header.Get("Authorization") + if authHeader == "" { + return "" + } + parsed, ok := httpauth.ParseAuthorizationHeader(authHeader) + if !ok { + return "" + } + switch { + case parsed.BearerToken != nil && codespace_service.IsGiteaTokenCandidate(parsed.BearerToken.Token): + return parsed.BearerToken.Token + case parsed.BasicAuth != nil: + username, password := parsed.BasicAuth.Username, parsed.BasicAuth.Password + if codespace_service.IsGiteaTokenCandidate(password) { + return password + } + if (password == "" || password == "x-oauth-basic") && codespace_service.IsGiteaTokenCandidate(username) { + return username + } + } + return "" +} + +func storeCodespaceTokenAuth(store DataStore, codespaceToken *codespace_service.GiteaTokenAuthSnapshot) { + store.GetData()["LoginMethod"] = CodespaceTokenMethodName + store.GetData()["IsApiToken"] = true + store.GetData()["ApiTokenScope"] = codespaceToken.Scope + store.GetData()[codespace_model.GiteaTokenAuthDataKey] = codespaceToken +} + +// CodespaceTokenSnapshot returns the Codespace Token auth snapshot stored on the request. +func CodespaceTokenSnapshot(store DataStore) (*codespace_service.GiteaTokenAuthSnapshot, bool) { + snapshot, ok := store.GetData()[codespace_model.GiteaTokenAuthDataKey].(*codespace_service.GiteaTokenAuthSnapshot) + return snapshot, ok && snapshot != nil +} + +var _ Method = &CodespaceToken{} diff --git a/services/auth/codespace_test.go b/services/auth/codespace_test.go new file mode 100644 index 0000000000000..86565d915f4b6 --- /dev/null +++ b/services/auth/codespace_test.go @@ -0,0 +1,284 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "crypto/ed25519" + "crypto/rand" + "net/http" + "testing" + "time" + + auth_model "gitea.dev/models/auth" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + "gitea.dev/modules/reqctx" + "gitea.dev/modules/setting" + codespace_service "gitea.dev/services/codespace" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +func TestCodespaceTokenBasicAuth(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + token, codespaceUUID := createAuthCodespaceToken(t) + req, err := http.NewRequest(http.MethodGet, "https://example.test/api/v1/user", nil) + require.NoError(t, err) + req.SetBasicAuth(token, "x-oauth-basic") + store := make(reqctx.ContextData) + + u, err := new(Basic).Verify(req, nil, store, nil) + require.NoError(t, err) + require.NotNil(t, u) + assert.EqualValues(t, 1, u.ID) + assert.Equal(t, CodespaceTokenMethodName, store.GetData()["LoginMethod"]) + assert.Equal(t, true, store.GetData()["IsApiToken"]) + scope := store.GetData()["ApiTokenScope"].(auth_model.AccessTokenScope) + assertContainsCodespaceScopes(t, scope) + snapshot := store.GetData()[codespace_model.GiteaTokenAuthDataKey].(*codespace_service.GiteaTokenAuthSnapshot) + assert.Equal(t, codespaceUUID, snapshot.CodespaceUUID) + assert.EqualValues(t, 2, snapshot.RepoID) +} + +func TestCodespaceTokenBearerAuth(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + token, _ := createAuthCodespaceToken(t) + req, err := http.NewRequest(http.MethodGet, "https://example.test/api/v1/user", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + store := make(reqctx.ContextData) + + u, err := new(OAuth2).Verify(req, nil, store, nil) + require.NoError(t, err) + require.NotNil(t, u) + assert.EqualValues(t, 1, u.ID) + assert.Equal(t, CodespaceTokenMethodName, store.GetData()["LoginMethod"]) + assertContainsCodespaceScopes(t, store.GetData()["ApiTokenScope"].(auth_model.AccessTokenScope)) +} + +func TestCodespaceTokenQueryAuthIsIgnored(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + token, _ := createAuthCodespaceToken(t) + for _, queryName := range []string{"token", "access_token"} { + req, err := http.NewRequest(http.MethodGet, "https://example.test/api/v1/user?"+queryName+"="+token, nil) + require.NoError(t, err) + + u, err := new(OAuth2).Verify(req, nil, make(reqctx.ContextData), nil) + require.NoError(t, err) + assert.Nil(t, u) + } +} + +func TestCodespaceTokenBasicAuthHonorsWebRoutePermission(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + token, _ := createAuthCodespaceToken(t) + req, err := http.NewRequest(http.MethodGet, "https://example.test/user2/repo1/releases/download/v1/file.zip", nil) + require.NoError(t, err) + req = req.WithContext(reqctx.NewRequestContextForTest(req.Context())) + req.SetBasicAuth(token, "x-oauth-basic") + SetCodespaceTokenAuthAllowed(req.Context(), false) + + u, err := new(Basic).Verify(req, nil, make(reqctx.ContextData), nil) + assert.Nil(t, u) + require.Error(t, err) + assert.True(t, IsCodespaceTokenForbidden(err)) +} + +func TestCodespaceTokenBasicAuthAllowsMarkedWebRoute(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + token, _ := createAuthCodespaceToken(t) + req, err := http.NewRequest(http.MethodGet, "https://example.test/user2/repo1.git/info/refs", nil) + require.NoError(t, err) + req = req.WithContext(reqctx.NewRequestContextForTest(req.Context())) + req.SetBasicAuth(token, "x-oauth-basic") + SetCodespaceTokenAuthAllowed(req.Context(), true) + + u, err := new(Basic).Verify(req, nil, make(reqctx.ContextData), nil) + require.NoError(t, err) + require.NotNil(t, u) + assert.EqualValues(t, 1, u.ID) +} + +func TestCodespaceTokenBearerAuthAllowsMarkedWebRoute(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + token, _ := createAuthCodespaceToken(t) + req, err := http.NewRequest(http.MethodGet, "https://example.test/user2/repo1.git/info/lfs/objects/batch", nil) + require.NoError(t, err) + req = req.WithContext(reqctx.NewRequestContextForTest(req.Context())) + req.Header.Set("Authorization", "Bearer "+token) + SetCodespaceTokenAuthAllowed(req.Context(), true) + group := NewGroup(&Basic{}, &CodespaceToken{}) + + u, err := group.Verify(req, nil, make(reqctx.ContextData), nil) + require.NoError(t, err) + require.NotNil(t, u) + assert.EqualValues(t, 1, u.ID) +} + +func TestCodespaceTokenQueryAuthHonorsDisableQueryToken(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + token, _ := createAuthCodespaceToken(t) + original := setting.DisableQueryAuthToken + setting.DisableQueryAuthToken = true + t.Cleanup(func() { setting.DisableQueryAuthToken = original }) + req, err := http.NewRequest(http.MethodGet, "https://example.test/api/v1/user?token="+token, nil) + require.NoError(t, err) + + u, err := new(OAuth2).Verify(req, nil, make(reqctx.ContextData), nil) + require.NoError(t, err) + assert.Nil(t, u) +} + +func TestCodespaceTokenQueryAuthIgnoredForWebAuth(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + token, _ := createAuthCodespaceToken(t) + req, err := http.NewRequest(http.MethodGet, "https://example.test/user2/repo1?token="+token, nil) + require.NoError(t, err) + req = req.WithContext(reqctx.NewRequestContextForTest(req.Context())) + SetCodespaceTokenAuthAllowed(req.Context(), true) + + u, err := new(OAuth2).Verify(req, nil, make(reqctx.ContextData), nil) + require.NoError(t, err) + assert.Nil(t, u) + + u, err = new(CodespaceToken).Verify(req, nil, make(reqctx.ContextData), nil) + require.NoError(t, err) + assert.Nil(t, u) +} + +func TestCodespaceTokenRejectedStopsAuthGroupFallback(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://example.test/api/v1/user", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer gcs_bad") + fallback := &fallbackAuthMethod{} + group := NewGroup(&OAuth2{}, fallback) + + u, err := group.Verify(req, nil, make(reqctx.ContextData), nil) + assert.Nil(t, u) + require.Error(t, err) + assert.ErrorIs(t, err, ErrAuthMethodTerminal) + assert.False(t, fallback.called) +} + +func TestCodespaceTokenRejectValidStopsFallback(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + token, _ := createAuthCodespaceToken(t) + req, err := http.NewRequest(http.MethodGet, "https://example.test/user/settings", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + fallback := &fallbackAuthMethod{} + group := NewGroup(&CodespaceToken{RejectValid: true}, fallback) + + u, err := group.Verify(req, nil, make(reqctx.ContextData), nil) + assert.Nil(t, u) + require.Error(t, err) + assert.True(t, IsCodespaceTokenForbidden(err)) + assert.False(t, fallback.called) +} + +func TestCodespaceTokenUnavailableStateIsForbidden(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + token, codespaceUUID := createAuthCodespaceToken(t) + _, err := db.GetEngine(t.Context()). + ID(codespaceUUID). + Cols("status"). + Update(&codespace_model.Codespace{Status: codespace_model.StatusStopped}) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodGet, "https://example.test/api/v1/user", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + + u, err := new(OAuth2).Verify(req, nil, make(reqctx.ContextData), nil) + assert.Nil(t, u) + require.Error(t, err) + assert.True(t, IsCodespaceTokenForbidden(err)) +} + +type fallbackAuthMethod struct { + called bool +} + +func (m *fallbackAuthMethod) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) { + m.called = true + return user_model.NewGhostUser(), nil +} + +func (m *fallbackAuthMethod) Name() string { + return "fallback" +} + +func createAuthCodespaceToken(t *testing.T) (string, string) { + t.Helper() + original := setting.Codespace.Enabled + setting.Codespace.Enabled = true + t.Cleanup(func() { setting.Codespace.Enabled = original }) + + manager := &codespace_model.Manager{ + Name: "manager", + RuntimeState: codespace_model.ManagerRuntimeStateOnline, + TagsJSON: "[]", + CreatedUnix: time.Now().Unix(), + LastOnlineUnix: time.Now().Unix(), + } + manager.GenerateManagerSecret() + require.NoError(t, db.Insert(t.Context(), manager)) + + codespaceUUID := "12121212-1212-4212-8212-121212121212" + require.NoError(t, db.Insert(t.Context(), &codespace_model.Codespace{ + UUID: codespaceUUID, + UserID: 1, + RepoID: 2, + RefType: "branch", + RefName: "main", + EnvironmentTag: "default", + CommitSHA: "0123456789abcdef0123456789abcdef01234567", + ManagerID: manager.ID, + Status: codespace_model.StatusRunning, + OperationRVersion: 1, + AutoStopMode: codespace_model.AutoStopModeDefault, + CreatedUnix: time.Now().Unix(), + UpdatedUnix: time.Now().Unix(), + })) + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + publicKey, err := ssh.NewPublicKey(privateKey.Public()) + require.NoError(t, err) + result, err := codespace_service.RequestRuntimeAccess(t.Context(), manager, codespace_service.RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 1, + GitSSHPublicKey: publicKey.Marshal(), + }) + require.NoError(t, err) + return result.Token, codespaceUUID +} + +func assertContainsCodespaceScopes(t *testing.T, scope auth_model.AccessTokenScope) { + t.Helper() + for _, required := range []auth_model.AccessTokenScope{ + auth_model.AccessTokenScopeWriteIssue, + auth_model.AccessTokenScopeWriteRepository, + auth_model.AccessTokenScopeReadUser, + } { + ok, err := scope.HasScope(required) + require.NoError(t, err) + assert.True(t, ok) + } + ok, err := scope.HasScope(auth_model.AccessTokenScopeWriteUser) + require.NoError(t, err) + assert.False(t, ok) +} diff --git a/services/auth/group.go b/services/auth/group.go index d9c2d74460eab..c682ec82f22b8 100644 --- a/services/auth/group.go +++ b/services/auth/group.go @@ -4,6 +4,7 @@ package auth import ( + "errors" "net/http" "strings" @@ -50,6 +51,9 @@ func (b *Group) Verify(req *http.Request, w http.ResponseWriter, store DataStore if retErr == nil { retErr = err } + if errors.Is(err, ErrAuthMethodTerminal) { + return nil, err + } // Try other methods if this one failed. // Some methods may share the same protocol to detect if they are matched. // For example, OAuth2 and conan.Auth both read token from "Authorization: Bearer " header, diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index cb622c2258147..2b5174ff9c0d3 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -20,6 +20,7 @@ import ( "gitea.dev/modules/timeutil" "gitea.dev/modules/util" "gitea.dev/services/actions" + codespace_service "gitea.dev/services/codespace" "gitea.dev/services/oauth2_provider" ) @@ -85,11 +86,15 @@ func parseToken(req *http.Request) (string, bool) { if !setting.DisableQueryAuthToken { // Check token. if token := req.Form.Get("token"); token != "" { - return token, true + if !codespace_service.IsGiteaTokenCandidate(token) { + return token, true + } } // Check access token. if token := req.Form.Get("access_token"); token != "" { - return token, true + if !codespace_service.IsGiteaTokenCandidate(token) { + return token, true + } } } else if req.Form.Get("token") != "" || req.Form.Get("access_token") != "" { log.Warn("API token sent in query string but DISABLE_QUERY_AUTH_TOKEN=true") @@ -109,6 +114,19 @@ func parseToken(req *http.Request) (string, bool) { // It will set 'IsApiToken' to true if the token is an API token and // set 'ApiTokenScope' to the scope of the access token (TODO: this behavior should be fixed, don't set ctx.Data) func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataStore) (*user_model.User, error) { + if codespace_service.IsGiteaTokenPlaintext(tokenSHA) && !codespaceTokenAuthAllowed(ctx) { + return nil, errors.Join(ErrAuthMethodTerminal, ErrCodespaceTokenForbidden) + } + codespaceToken, err := codespace_service.ResolveGiteaToken(ctx, tokenSHA) + if err != nil { + if authErr := codespaceTokenAuthError(err); authErr != nil { + return nil, authErr + } + } else { + storeCodespaceTokenAuth(store, codespaceToken) + return codespaceToken.User, nil + } + // Let's see if token is valid. if strings.Contains(tokenSHA, ".") { // First attempt to decode an actions JWT, returning the actions user diff --git a/services/codespace/configuration.go b/services/codespace/configuration.go new file mode 100644 index 0000000000000..b34b20786049f --- /dev/null +++ b/services/codespace/configuration.go @@ -0,0 +1,320 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/modules/setting" + + "google.golang.org/protobuf/proto" +) + +// Init validates Codespace runtime entrypoint configuration during web startup. +func Init(ctx context.Context) error { + if !setting.Codespace.Enabled { + return nil + } + if err := ValidateCodespaceConfig(); err != nil { + return err + } + if err := ValidateGitTransports(); err != nil { + return err + } + return WarnManagerGatewayAddressConflicts(ctx) +} + +// ValidateCodespaceConfig verifies cross-field Codespace settings before runtime entrypoints start. +func ValidateCodespaceConfig() error { + if setting.Codespace.ControlPlaneTimeout <= 0 { + return errors.New("[codespace] CONTROL_PLANE_TIMEOUT must be positive") + } + if setting.Codespace.ControlPlaneMaxSize <= 0 { + return errors.New("[codespace] CONTROL_PLANE_MAX_MESSAGE_SIZE must be positive") + } + if setting.Codespace.ManagerOfflineTimeout <= 0 { + return errors.New("[codespace] MANAGER_OFFLINE_TIMEOUT must be positive") + } + if setting.Codespace.OperationLeaseTimeout <= 0 { + return errors.New("[codespace] OPERATION_LEASE_TIMEOUT must be positive") + } + if setting.Codespace.OperationLeaseTimeout%time.Millisecond != 0 { + return errors.New("[codespace] OPERATION_LEASE_TIMEOUT must be a positive whole number of milliseconds") + } + if setting.Codespace.OperationMaxDuration <= setting.Codespace.OperationLeaseTimeout { + return errors.New("[codespace] OPERATION_MAX_DURATION must be greater than OPERATION_LEASE_TIMEOUT") + } + if setting.Codespace.OperationMaxDuration%time.Second != 0 { + return errors.New("[codespace] OPERATION_MAX_DURATION must be a positive whole number of seconds") + } + if setting.Codespace.ManagerOfflineTimeout%time.Second != 0 { + return errors.New("[codespace] MANAGER_OFFLINE_TIMEOUT must be a positive whole number of seconds") + } + if setting.Codespace.QueueTimeout <= 0 { + return errors.New("[codespace] QUEUE_TIMEOUT must be positive") + } + if setting.Codespace.QueueTimeout%time.Second != 0 { + return errors.New("[codespace] QUEUE_TIMEOUT must be a positive whole number of seconds") + } + if setting.Codespace.OpenTokenExpire <= 0 || setting.Codespace.OpenTokenExpire%time.Second != 0 { + return errors.New("[codespace] OPEN_TOKEN_EXPIRE must be a positive whole number of seconds") + } + if setting.Codespace.ControlPlaneTimeout > setting.Codespace.ManagerOfflineTimeout/4 { + return errors.New("[codespace] CONTROL_PLANE_TIMEOUT must be no greater than MANAGER_OFFLINE_TIMEOUT/4") + } + if setting.Codespace.AutoStopMinTimeout <= 0 || + setting.Codespace.AutoStopDefaultTimeout <= 0 || + setting.Codespace.AutoStopMaxTimeout <= 0 { + return errors.New("[codespace] AUTO_STOP timeouts must be positive") + } + if setting.Codespace.AutoStopMinTimeout%time.Second != 0 || + setting.Codespace.AutoStopDefaultTimeout%time.Second != 0 || + setting.Codespace.AutoStopMaxTimeout%time.Second != 0 { + return errors.New("[codespace] AUTO_STOP timeouts must be whole seconds") + } + if setting.Codespace.AutoStopMinTimeout > setting.Codespace.AutoStopDefaultTimeout || + setting.Codespace.AutoStopDefaultTimeout > setting.Codespace.AutoStopMaxTimeout { + return errors.New("[codespace] AUTO_STOP_MIN_TIMEOUT <= AUTO_STOP_DEFAULT_TIMEOUT <= AUTO_STOP_MAX_TIMEOUT is required") + } + if setting.Codespace.LogMaxSize <= 0 { + return errors.New("[codespace] LOG_MAX_SIZE must be positive") + } + if LogReadMaxBytes >= setting.Codespace.LogMaxSize || + codespaceLogInternalSummaryReserve >= setting.Codespace.LogMaxSize { + return errors.New("[codespace] LOG_MAX_SIZE must be greater than the internal log page and state summary reserve sizes") + } + if setting.Codespace.RuntimeMetadataMaxSize <= 0 { + return errors.New("[codespace] RUNTIME_METADATA_MAX_SIZE must be positive") + } + minControlPlaneSize, minControlPlaneMessage := minimumControlPlaneMaxMessageSize() + if setting.Codespace.ControlPlaneMaxSize < minControlPlaneSize { + return fmt.Errorf("[codespace] CONTROL_PLANE_MAX_MESSAGE_SIZE=%d must be at least %d bytes for %s", setting.Codespace.ControlPlaneMaxSize, minControlPlaneSize, minControlPlaneMessage) + } + if setting.Codespace.DevContainerConfigMaxSize <= 0 { + return errors.New("[codespace] DEVCONTAINER_CONFIG_MAX_SIZE must be positive") + } + if strings.TrimSpace(setting.Codespace.DevContainerDefaultImage) == "" { + return errors.New("[codespace] DEVCONTAINER_DEFAULT_IMAGE must not be empty") + } + return nil +} + +type gitTransportCapabilities struct { + HTTPEnabled bool + SSHEnabled bool + HTTPDisabled string + SSHDisabled string +} + +// ValidateGitTransports verifies that new Codespaces have a usable Git clone entrypoint. +func ValidateGitTransports() error { + protocol, err := createGitProtocol() + if err != nil { + return err + } + _, err = resolveGitTransportCapabilities(protocol) + return err +} + +func resolveGitTransportCapabilities(protocol string) (*gitTransportCapabilities, error) { + capabilities := &gitTransportCapabilities{ + HTTPEnabled: true, + } + var unavailable []string + if setting.Repository.DisableHTTPGit { + capabilities.HTTPEnabled = false + capabilities.HTTPDisabled = "[repository] DISABLE_HTTP_GIT=true" + unavailable = append(unavailable, "http: "+capabilities.HTTPDisabled) + } + if disabled := gitSSHCloneDisabledReason(); disabled != "" { + capabilities.SSHDisabled = disabled + unavailable = append(unavailable, "ssh: "+disabled) + } else { + capabilities.SSHEnabled = true + } + + if !capabilities.HTTPEnabled && !capabilities.SSHEnabled { + return nil, fmt.Errorf("codespace git transport unavailable: %s", strings.Join(unavailable, "; ")) + } + switch protocol { + case codespace_model.GitProtocolHTTP: + if !capabilities.HTTPEnabled { + return nil, fmt.Errorf("codespace git transport unavailable: http: %s", capabilities.HTTPDisabled) + } + case codespace_model.GitProtocolSSH: + if !capabilities.SSHEnabled { + return nil, fmt.Errorf("codespace git transport unavailable: ssh: %s", capabilities.SSHDisabled) + } + default: + return nil, fmt.Errorf("invalid codespace git protocol %q", protocol) + } + return capabilities, nil +} + +// ManagerServiceTimings returns server-selected ManagerService control values. +func ManagerServiceTimings() (heartbeatMillis, metadataRefreshMillis, maxMessageBytes int64, giteaWebURL string) { + return int64((setting.Codespace.ManagerOfflineTimeout / 4) / time.Millisecond), + int64((setting.Codespace.ManagerOfflineTimeout / 2) / time.Millisecond), + setting.Codespace.ControlPlaneMaxSize, + setting.AppURL +} + +func minimumControlPlaneMaxMessageSize() (int64, string) { + maxString := strings.Repeat("x", 512) + maxName := strings.Repeat("n", 255) + maxUUID := "ffffffff-ffff-4fff-8fff-ffffffffffff" + maxRuntimeSettings := &codespacev1.EffectiveCodespaceRuntimeSettings{ + AutoStopEnabled: true, + IdleTimeoutSeconds: 86_400, + InteractionGeneration: 1<<62 - 1, + } + + var minSize int64 + var minName string + track := func(name string, message proto.Message) { + if size := int64(proto.Size(message)); size > minSize { + minSize = size + minName = name + } + } + declareRequest := &codespacev1.DeclareManagerRequest{ + ProtocolVersion: 1, + Environments: make([]*codespacev1.EnvironmentTag, 0, 64), + } + for range 64 { + declareRequest.Environments = append(declareRequest.Environments, &codespacev1.EnvironmentTag{ + Tag: strings.Repeat("t", 64), + Description: strings.Repeat("d", 255), + }) + } + track("DeclareManagerRequest", declareRequest) + + fetchRequest := &codespacev1.FetchOperationsRequest{ + ProtocolVersion: 1, + StartupCapacityAvailable: 10_000, + AcceptedOperationTypes: []codespacev1.AcceptedOperationType{codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_CREATE, codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_RESUME}, + CleanupCapacityAvailable: 256, + ObservedOperations: make([]*codespacev1.ObservedOperation, 0, fetchMaxObservedOperations), + AcceptedCreateTags: make([]string, 64), + } + for i := range fetchRequest.AcceptedCreateTags { + fetchRequest.AcceptedCreateTags[i] = strings.Repeat("t", 64) + } + reportRequest := &codespacev1.ReportInstancesRequest{ + ProtocolVersion: 1, + InventoryGeneration: 1<<62 - 1, + Instances: make([]*codespacev1.RuntimeInstanceRef, 0, fetchMaxObservedOperations), + } + reportResponse := &codespacev1.ReportInstancesResponse{ + Results: make([]*codespacev1.RuntimeInstanceResult, 0, fetchMaxObservedOperations), + } + for i := range fetchMaxObservedOperations { + uuid := fmt.Sprintf("%08x-ffff-4fff-8fff-ffffffffffff", i) + fetchRequest.ObservedOperations = append(fetchRequest.ObservedOperations, &codespacev1.ObservedOperation{ + CodespaceUuid: uuid, + OperationRversion: 1<<62 - 1, + }) + reportRequest.Instances = append(reportRequest.Instances, &codespacev1.RuntimeInstanceRef{ + CodespaceUuid: uuid, + RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, + ObservedOperationRversion: 1<<62 - 1, + }) + reportResponse.Results = append(reportResponse.Results, &codespacev1.RuntimeInstanceResult{ + CodespaceUuid: uuid, + RuntimeSettings: maxRuntimeSettings, + Action: codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_STOP_LOCAL_RUNTIME, + CurrentOperationRversion: 1<<62 - 1, + }) + } + track("FetchOperationsRequest", fetchRequest) + track("ReportInstancesRequest", reportRequest) + track("ReportInstancesResponse", reportResponse) + + createOperation := &codespacev1.OperationPayload{ + OperationRversion: 1<<62 - 1, + CodespaceUuid: maxUUID, + LogOffset: 1<<62 - 1, + LeaseValidForMilliseconds: int64(setting.Codespace.OperationLeaseTimeout / time.Millisecond), + Command: &codespacev1.OperationPayload_Create{Create: &codespacev1.CreateOperationPayload{ + RepoFullName: maxString, + RepoCloneHttpUrl: maxString, + RepoCloneSshUrl: maxString, + GitProtocol: codespacev1.GitProtocol_GIT_PROTOCOL_SSH, + StartRef: maxString, + CommitSha: strings.Repeat("f", 64), + EnvironmentTag: strings.Repeat("t", 64), + RuntimeSettings: maxRuntimeSettings, + Username: maxName, + GitUserEmail: maxString, + DevContainer: &codespacev1.DevContainerConfiguration{ + RepositoryPath: strings.Repeat("p", 512), + RepositoryContentSha256: strings.Repeat("f", 64), + }, + }}, + } + fetchResponse := &codespacev1.FetchOperationsResponse{ + Operations: make([]*codespacev1.OperationPayload, 0, fetchMaxOperations), + RenewedLeases: make([]*codespacev1.RenewedOperationLease, 0, fetchMaxObservedOperations), + } + for range fetchMaxOperations { + fetchResponse.Operations = append(fetchResponse.Operations, createOperation) + } + for i := range fetchMaxObservedOperations { + fetchResponse.RenewedLeases = append(fetchResponse.RenewedLeases, &codespacev1.RenewedOperationLease{ + CodespaceUuid: fmt.Sprintf("%08x-ffff-4fff-8fff-ffffffffffff", i), + OperationRversion: 1<<62 - 1, + LeaseValidForMilliseconds: int64(setting.Codespace.OperationLeaseTimeout / time.Millisecond), + }) + } + if size := int64(proto.Size(fetchResponse)); size > minSize { + minSize = size + minName = "FetchOperationsResponse" + } + track("UpdateLogRequest", &codespacev1.UpdateLogRequest{ + ProtocolVersion: 1, + CodespaceUuid: maxUUID, + OperationRversion: 1<<62 - 1, + Offset: 1<<62 - 1, + Lines: []*codespacev1.LogLine{{ + TimestampUnixNano: 1<<62 - 1, + Message: strings.Repeat("l", int(codespaceLogMaxLineSize)), + }}, + }) + metadataEndpoints := make([]*codespacev1.RuntimeEndpoint, 0, 64) + metadataEndpoints = append(metadataEndpoints, &codespacev1.RuntimeEndpoint{EndpointId: workspaceEndpointID, Label: workspaceEndpointLabel}) + for i := range 63 { + metadataEndpoints = append(metadataEndpoints, &codespacev1.RuntimeEndpoint{ + EndpointId: fmt.Sprintf("app-%02d", i), + Label: strings.Repeat("m", 64), + Public: i%2 == 0, + }) + } + track("ReportRuntimeMetadataRequest", &codespacev1.ReportRuntimeMetadataRequest{ + ProtocolVersion: 1, + CodespaceUuid: maxUUID, + Metadata: &codespacev1.RuntimeMetadata{ + Endpoints: metadataEndpoints, + Boot: &codespacev1.RuntimeBoot{ + OperationRversion: 1<<62 - 1, + Stage: codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_READY, + StartedUnix: 1<<62 - 1, + LastUpdateUnix: 1<<62 - 1, + }, + ResourceUsage: &codespacev1.RuntimeResourceUsage{ + Cpu: &codespacev1.RuntimeCPUUsage{UsedMillicores: 1<<62 - 1, LimitMillicores: 1<<62 - 1}, + Memory: &codespacev1.RuntimeMemoryUsage{UsedBytes: 1<<62 - 1, LimitBytes: 1<<62 - 1}, + Disk: &codespacev1.RuntimeDiskUsage{UsedBytes: 1<<62 - 1, LimitBytes: 1<<62 - 1}, + ObservedUnix: 1<<62 - 1, + }, + }, + MetadataGeneration: 1<<62 - 1, + }) + return minSize, minName +} diff --git a/services/codespace/create.go b/services/codespace/create.go new file mode 100644 index 0000000000000..c135fe1a8fce3 --- /dev/null +++ b/services/codespace/create.go @@ -0,0 +1,756 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + git_model "gitea.dev/models/git" + issues_model "gitea.dev/models/issues" + perm_model "gitea.dev/models/perm" + access_model "gitea.dev/models/perm/access" + repo_model "gitea.dev/models/repo" + unit_model "gitea.dev/models/unit" + user_model "gitea.dev/models/user" + "gitea.dev/modules/git" + "gitea.dev/modules/globallock" + "gitea.dev/modules/setting" + repository_service "gitea.dev/services/repository" +) + +var ( + // ErrCreatePermissionDenied is returned when the user cannot create a Codespace for the repository. + ErrCreatePermissionDenied = errors.New("codespace create permission denied") + // ErrCreateStateUnavailable is returned when Codespace is not accepting new creates. + ErrCreateStateUnavailable = errors.New("codespace create state unavailable") + // ErrCreateEnvironmentUnavailable is returned when the selected environment is not available to the user. + ErrCreateEnvironmentUnavailable = errors.New("codespace create environment unavailable") + // ErrCreateRequestChanged is returned when the reviewed repository configuration is no longer current. + ErrCreateRequestChanged = errors.New("codespace create request changed") +) + +// CreateCodespaceOptions contains a creator request from a repository page. +type CreateCodespaceOptions struct { + User *user_model.User + Repo *repo_model.Repository + RefType string + RefName string + RequestHash string + DevContainerSelection string + EnvironmentTag string + PermissionGrants map[string]string + RecommendedSecretValues map[string]string + RecommendedSecretEnabled map[string]bool +} + +// CreateCodespaceResult contains the new object identity and initial state. +type CreateCodespaceResult struct { + CodespaceUUID string + Status string + EnvironmentTag string +} + +// CreateCodespacePlan is a side-effect-free description presented for user confirmation. +type CreateCodespacePlan struct { + RequestHash string + RefType string + RefName string + PullRequest *CreatePullRequestSource + DevContainerOptions []CreateDevContainerOption + Environments []CreateEnvironmentOption + Permissions []CreatePermissionRequest + RecommendedSecrets []CreateRecommendedSecret + AvailableSecrets []CreateSecretSummary + SecretInjectionAllowed bool +} + +// CreateEnvironmentOption describes one visible Manager environment on the confirmation page. +type CreateEnvironmentOption struct { + Tag string + Description string + Site bool + Personal bool + Online bool + Selected bool +} + +// CreateRecommendedSecret describes one Dev Container recommended secret without exposing its value. +type CreateRecommendedSecret struct { + Name string + Description string + Configured bool + Available bool +} + +// CreateSecretSummary identifies one secret currently available to the source repository without exposing its value. +type CreateSecretSummary struct { + Name string + Description string +} + +// CreatePermissionRequest describes one repository permission used by the Codespace. +type CreatePermissionRequest struct { + RepositoryID int64 + RepositoryFullName string + UnitType unit_model.Type + UnitName string + Mode perm_model.AccessMode + ModeName string + FormName string + Required bool +} + +// CreatePullRequestSource describes the pull request shown on the confirmation page. +type CreatePullRequestSource struct { + Index int64 + HeadRepoFullName string + HeadBranch string + BaseBranch string + SnapshotOnly bool + IsFork bool +} + +type createSourceRef struct { + Type string + StoredName string + FormName string + CommitSHA string + PullRequest *CreatePullRequestSource + pullHeadRepositoryID int64 +} + +type preparedCodespace struct { + sourceRef *createSourceRef + devContainer *createDevContainerPlan + devContainerOptions []CreateDevContainerOption + requestHash string + availableSecrets []CreateSecretSummary + secretInjectionAllowed bool +} + +var codespacePermissionUnits = map[string]unit_model.Type{ + "code": unit_model.TypeCode, + "issues": unit_model.TypeIssues, + "pulls": unit_model.TypePullRequests, + "wiki": unit_model.TypeWiki, + "releases": unit_model.TypeReleases, + "actions": unit_model.TypeActions, +} + +// PrepareCodespace validates a creation request without changing persistent state. +func PrepareCodespace(ctx context.Context, opts CreateCodespaceOptions) (*CreateCodespacePlan, error) { + prepared, err := prepareCodespace(ctx, opts) + if err != nil { + return nil, err + } + environments, err := listVisibleCreateEnvironments(ctx, opts.User.ID, opts.EnvironmentTag) + if err != nil { + return nil, err + } + return &CreateCodespacePlan{ + RequestHash: prepared.requestHash, + RefType: prepared.sourceRef.Type, + RefName: prepared.sourceRef.FormName, + PullRequest: prepared.sourceRef.PullRequest, + DevContainerOptions: prepared.devContainerOptions, + Environments: environments, + Permissions: prepared.devContainer.Permissions, + RecommendedSecrets: prepared.devContainer.RecommendedSecrets, + AvailableSecrets: prepared.availableSecrets, + SecretInjectionAllowed: prepared.secretInjectionAllowed, + }, nil +} + +// CreateCodespace validates repository input and creates the initial Codespace row. +func CreateCodespace(ctx context.Context, opts CreateCodespaceOptions) (*CreateCodespaceResult, error) { + if strings.TrimSpace(opts.RequestHash) == "" { + return nil, errors.New("codespace create confirmation is required") + } + if opts.User == nil || opts.User.ID <= 0 { + return nil, errors.New("user is required") + } + if opts.Repo == nil || opts.Repo.ID <= 0 { + return nil, errors.New("repository is required") + } + + var result *CreateCodespaceResult + err := globallock.LockAndDo(ctx, codespaceUserRelationLockKey(opts.User.ID), func(ctx context.Context) error { + return globallock.LockAndDo(ctx, repository_service.WorkingLockKey(opts.Repo.ID), func(ctx context.Context) error { + prepared, err := prepareCodespace(ctx, opts) + if err != nil { + return err + } + if subtle.ConstantTimeCompare([]byte(prepared.requestHash), []byte(opts.RequestHash)) != 1 { + return ErrCreateRequestChanged + } + grantedModes, err := normalizePermissionGrants(prepared.devContainer.Permissions, opts.PermissionGrants) + if err != nil { + return err + } + return db.WithTx(ctx, func(ctx context.Context) error { + user, err := user_model.GetUserByID(ctx, opts.User.ID) + if err != nil { + return err + } + if err := validateCreateUser(user); err != nil { + return err + } + repo, err := repo_model.GetRepositoryByID(ctx, opts.Repo.ID) + if err != nil { + return err + } + if err := validateCreateRepository(repo); err != nil { + return err + } + canRead, err := access_model.HasAccessUnit(ctx, user, repo, unit_model.TypeCode, perm_model.AccessModeRead) + if err != nil { + return err + } + if !canRead { + return ErrCreatePermissionDenied + } + environmentTag := strings.ToLower(strings.TrimSpace(opts.EnvironmentTag)) + if !tagPattern.MatchString(environmentTag) { + return ErrCreateEnvironmentUnavailable + } + environments, err := listVisibleCreateEnvironments(ctx, user.ID, environmentTag) + if err != nil { + return err + } + available := false + for _, environment := range environments { + if environment.Tag == environmentTag { + available = true + break + } + } + if !available { + return ErrCreateEnvironmentUnavailable + } + authorizationID, err := ensurePermissionAuthorization(ctx, user.ID, repo.ID, permissionRequestHash(repo.ID, prepared.devContainer.Permissions), prepared.devContainer.Permissions, grantedModes) + if err != nil { + return err + } + if prepared.secretInjectionAllowed { + if err := configureRecommendedSecrets(ctx, user, repo, prepared.devContainer.RecommendedSecrets, opts.RecommendedSecretValues, opts.RecommendedSecretEnabled); err != nil { + return err + } + } + codespace := newCreateCodespaceRow(user.ID, repo.ID, environmentTag, prepared.sourceRef, prepared.devContainer, authorizationID) + if _, err := db.GetEngine(ctx).Insert(codespace); err != nil { + return err + } + result = &CreateCodespaceResult{ + CodespaceUUID: codespace.UUID, + Status: codespace.Status, + EnvironmentTag: codespace.EnvironmentTag, + } + return nil + }) + }) + }) + if err != nil { + return nil, err + } + return result, nil +} + +func prepareCodespace(ctx context.Context, opts CreateCodespaceOptions) (*preparedCodespace, error) { + if !setting.Codespace.Enabled { + return nil, ErrCreateStateUnavailable + } + if opts.User == nil || opts.User.ID <= 0 { + return nil, errors.New("user is required") + } + if opts.Repo == nil || opts.Repo.ID <= 0 { + return nil, errors.New("repository is required") + } + if err := validateCreateUser(opts.User); err != nil { + return nil, err + } + if err := validateCreateRepository(opts.Repo); err != nil { + return nil, err + } + canRead, err := access_model.HasAccessUnit(ctx, opts.User, opts.Repo, unit_model.TypeCode, perm_model.AccessModeRead) + if err != nil { + return nil, err + } + if !canRead { + return nil, ErrCreatePermissionDenied + } + gitProtocol, err := createGitProtocol() + if err != nil { + return nil, err + } + if _, err := resolveGitTransportCapabilities(gitProtocol); err != nil { + return nil, err + } + gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, opts.Repo) + if err != nil { + return nil, err + } + defer closer.Close() + sourceRef, err := resolveCreateSourceRef(ctx, opts.User, opts.Repo, gitRepo, opts.RefType, opts.RefName) + if err != nil { + return nil, err + } + devContainer, options, err := prepareCreateDevContainer(ctx, opts.User, opts.Repo, gitRepo, sourceRef, opts.DevContainerSelection) + if err != nil { + return nil, err + } + secretInjectionAllowed := sourceRef.PullRequest == nil || !sourceRef.PullRequest.IsFork + if secretInjectionAllowed { + secretInjectionAllowed, err = userCanUseSecretRepository(ctx, opts.User, opts.Repo) + if err != nil { + return nil, err + } + } + var availableSecrets []CreateSecretSummary + if secretInjectionAllowed { + devContainer.RecommendedSecrets, availableSecrets, err = resolveCreateSecrets(ctx, opts.User.ID, opts.Repo.ID, devContainer.RecommendedSecrets) + if err != nil { + return nil, err + } + } + if pull := sourceRef.PullRequest; pull != nil && !pull.SnapshotOnly && pull.HeadRepoFullName != opts.Repo.FullName() { + required := CreatePermissionRequest{ + RepositoryID: sourceRef.pullHeadRepositoryID, + RepositoryFullName: pull.HeadRepoFullName, + UnitType: unit_model.TypeCode, + UnitName: "code", + Mode: perm_model.AccessModeWrite, + ModeName: "write", + FormName: fmt.Sprintf("permission_%d_%d", sourceRef.pullHeadRepositoryID, unit_model.TypeCode), + Required: true, + } + replaced := false + for i, permission := range devContainer.Permissions { + if permission.RepositoryID == required.RepositoryID && permission.UnitType == required.UnitType { + devContainer.Permissions[i] = required + replaced = true + break + } + } + if !replaced { + devContainer.Permissions = append(devContainer.Permissions, required) + } + sort.Slice(devContainer.Permissions, func(i, j int) bool { + if devContainer.Permissions[i].RepositoryID != devContainer.Permissions[j].RepositoryID { + return devContainer.Permissions[i].RepositoryID < devContainer.Permissions[j].RepositoryID + } + return devContainer.Permissions[i].UnitType < devContainer.Permissions[j].UnitType + }) + } + return &preparedCodespace{ + sourceRef: sourceRef, + devContainer: devContainer, + devContainerOptions: options, + requestHash: createPlanHash(opts.Repo.ID, sourceRef, devContainer), + availableSecrets: availableSecrets, + secretInjectionAllowed: secretInjectionAllowed, + }, nil +} + +func validateCreateUser(user *user_model.User) error { + if user == nil || user.ID <= 0 { + return errors.New("user is required") + } + if !user.IsActive || user.ProhibitLogin || user.MustChangePassword { + return ErrCreatePermissionDenied + } + return nil +} + +func validateCreateRepository(repo *repo_model.Repository) error { + if repo == nil || repo.ID <= 0 { + return errors.New("repository is required") + } + if repo.IsEmpty { + return errors.New("repository is empty") + } + if repo.IsArchived || repo.IsBeingCreated() || repo.IsBroken() { + return errors.New("repository state does not allow codespace creation") + } + return nil +} + +func resolveCreateSourceRef(ctx context.Context, user *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, refType, rawRef string) (*createSourceRef, error) { + refType = strings.ToLower(strings.TrimSpace(refType)) + ref := strings.TrimSpace(rawRef) + if refType == "" { + refType = "branch" + } + switch refType { + case "branch": + if ref == "" { + ref = repo.DefaultBranch + } + if branch, ok := strings.CutPrefix(ref, "refs/heads/"); ok { + ref = branch + } + if ref == "" { + return nil, errors.New("branch is required") + } + commit, err := gitRepo.GetBranchCommit(ctx, ref) + if err != nil { + return nil, err + } + return &createSourceRef{Type: "branch", StoredName: ref, FormName: ref, CommitSHA: commit.ID.String()}, nil + case "tag": + if tag, ok := strings.CutPrefix(ref, "refs/tags/"); ok { + ref = tag + } + if ref == "" { + return nil, errors.New("tag is required") + } + commit, err := gitRepo.GetTagCommit(ctx, ref) + if err != nil { + return nil, err + } + return &createSourceRef{Type: "tag", StoredName: ref, FormName: ref, CommitSHA: commit.ID.String()}, nil + case "commit": + if ref == "" { + return nil, errors.New("commit is required") + } + objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName) + if len(ref) != objectFormat.FullLength() || !git.IsStringLikelyCommitID(objectFormat, ref) { + return nil, fmt.Errorf("invalid commit: %s", ref) + } + commit, err := gitRepo.GetCommit(ctx, ref) + if err != nil { + return nil, err + } + return &createSourceRef{Type: "commit", StoredName: commit.ID.String(), FormName: commit.ID.String(), CommitSHA: commit.ID.String()}, nil + case "pull": + return resolveCreatePull(ctx, user, repo, gitRepo, ref) + default: + return nil, fmt.Errorf("unsupported ref type: %s", refType) + } +} + +func resolveCreatePull(ctx context.Context, user *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, rawIndex string) (*createSourceRef, error) { + index, err := strconv.ParseInt(strings.TrimSpace(rawIndex), 10, 64) + if err != nil || index <= 0 { + return nil, errors.New("pull request index is required") + } + pr, err := issues_model.GetPullRequestByIndex(ctx, repo.ID, index) + if err != nil { + return nil, err + } + if pr.BaseRepoID != repo.ID { + return nil, errors.New("pull request base repository mismatch") + } + if err := pr.LoadIssue(ctx); err != nil { + return nil, err + } + if pr.Issue.IsClosed { + return nil, errors.New("pull request is closed") + } + pullSource := &CreatePullRequestSource{ + Index: index, + HeadBranch: pr.HeadBranch, + BaseBranch: pr.BaseBranch, + SnapshotOnly: pr.IsAgitFlow(), + IsFork: pr.HeadRepoID != pr.BaseRepoID, + } + if !pullSource.SnapshotOnly { + if err := pr.LoadHeadRepo(ctx); err != nil { + return nil, err + } + if pr.HeadRepo == nil { + return nil, errors.New("pull request head repository not found") + } + if err := validateCreateRepository(pr.HeadRepo); err != nil { + return nil, err + } + exists, err := git_model.IsBranchExist(ctx, pr.HeadRepo.ID, pr.HeadBranch) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.New("pull request head branch not found") + } + canRead, err := access_model.HasAccessUnit(ctx, user, pr.HeadRepo, unit_model.TypeCode, perm_model.AccessModeRead) + if err != nil { + return nil, err + } + if !canRead { + return nil, ErrCreatePermissionDenied + } + pullSource.HeadRepoFullName = pr.HeadRepo.FullName() + } else { + pullSource.HeadRepoFullName = repo.FullName() + } + refName := pr.GetGitHeadRefName() + commitSHA, err := gitRepo.GetRefCommitID(ctx, refName) + if err != nil { + return nil, err + } + return &createSourceRef{ + Type: "pull", + StoredName: refName, + FormName: strconv.FormatInt(index, 10), + CommitSHA: commitSHA, + PullRequest: pullSource, + pullHeadRepositoryID: pr.HeadRepoID, + }, nil +} + +func resolveCreatePermissions(ctx context.Context, user *user_model.User, sourceRepo *repo_model.Repository, requested map[string]map[string]string) ([]CreatePermissionRequest, error) { + permissions := make([]CreatePermissionRequest, 0) + for fullName, units := range requested { + ownerName, repoName, _ := strings.Cut(fullName, "/") + target, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName) + if err != nil { + return nil, fmt.Errorf("resolve codespace permission repository %q: %w", fullName, err) + } + if target.ID == sourceRepo.ID { + return nil, fmt.Errorf("codespace permission repository %q is the source repository", fullName) + } + for unitName, modeName := range units { + unitType := codespacePermissionUnits[unitName] + if !target.UnitEnabled(ctx, unitType) { + return nil, fmt.Errorf("codespace permission unit %q is not enabled for %q", unitName, fullName) + } + mode := perm_model.ParseAccessMode(modeName, perm_model.AccessModeRead, perm_model.AccessModeWrite) + allowed, err := access_model.HasAccessUnit(ctx, user, target, unitType, mode) + if err != nil { + return nil, err + } + if !allowed { + return nil, fmt.Errorf("user cannot grant %s access to %s for %q", modeName, unitName, fullName) + } + permissions = append(permissions, CreatePermissionRequest{ + RepositoryID: target.ID, RepositoryFullName: target.FullName(), UnitType: unitType, + UnitName: unitName, Mode: mode, ModeName: modeName, + FormName: fmt.Sprintf("permission_%d_%d", target.ID, unitType), + }) + } + } + sort.Slice(permissions, func(i, j int) bool { + if permissions[i].RepositoryID != permissions[j].RepositoryID { + return permissions[i].RepositoryID < permissions[j].RepositoryID + } + return permissions[i].UnitType < permissions[j].UnitType + }) + return permissions, nil +} + +func createPlanHash(repoID int64, sourceRef *createSourceRef, devContainer *createDevContainerPlan) string { + hash := sha256.New() + fmt.Fprintf(hash, "%d\x00%s\x00%s\x00%s\x00%s\x00%s\x00%s", repoID, sourceRef.Type, sourceRef.StoredName, sourceRef.CommitSHA, devContainer.Path, devContainer.ContentSHA256, devContainer.DefaultImage) + if pull := sourceRef.PullRequest; pull != nil { + fmt.Fprintf(hash, "\x00%d\x00%s\x00%s\x00%s\x00%t", pull.Index, pull.HeadRepoFullName, pull.HeadBranch, pull.BaseBranch, pull.SnapshotOnly) + } + for _, permission := range devContainer.Permissions { + fmt.Fprintf(hash, "\x00%d\x00%d\x00%d\x00%t", permission.RepositoryID, permission.UnitType, permission.Mode, permission.Required) + } + return hex.EncodeToString(hash.Sum(nil)) +} + +func permissionRequestHash(repoID int64, permissions []CreatePermissionRequest) string { + hash := sha256.New() + fmt.Fprintf(hash, "%d", repoID) + for _, permission := range permissions { + fmt.Fprintf(hash, "\x00%d\x00%d\x00%d", permission.RepositoryID, permission.UnitType, permission.Mode) + } + return hex.EncodeToString(hash.Sum(nil)) +} + +func createGitProtocol() (string, error) { + protocol := strings.ToLower(strings.TrimSpace(setting.Codespace.GitProtocol)) + switch protocol { + case "": + return codespace_model.GitProtocolHTTP, nil + case codespace_model.GitProtocolHTTP, codespace_model.GitProtocolSSH: + return protocol, nil + default: + return "", fmt.Errorf("invalid codespace git protocol %q", setting.Codespace.GitProtocol) + } +} + +func listVisibleCreateEnvironments(ctx context.Context, userID int64, selectedTag string) ([]CreateEnvironmentOption, error) { + var managers []*codespace_model.Manager + if err := db.GetEngine(ctx). + In("user_id", []int64{0, userID}). + Where("last_online_unix > 0"). + Asc("user_id", "id"). + Find(&managers); err != nil { + return nil, err + } + type aggregate struct { + option CreateEnvironmentOption + descriptions map[string]struct{} + } + byTag := make(map[string]*aggregate) + for _, manager := range managers { + environments, err := decodeManagerEnvironments(manager) + if err != nil { + return nil, err + } + for _, environment := range environments { + item := byTag[environment.Tag] + if item == nil { + item = &aggregate{ + option: CreateEnvironmentOption{Tag: environment.Tag}, + descriptions: make(map[string]struct{}), + } + byTag[environment.Tag] = item + } + if manager.UserID == 0 { + item.option.Site = true + } else { + item.option.Personal = true + } + if managerAllowsOnlineOrRecovering(manager) { + item.option.Online = true + } + if environment.Description != "" { + item.descriptions[environment.Description] = struct{}{} + } + } + } + selectedTag = strings.ToLower(strings.TrimSpace(selectedTag)) + result := make([]CreateEnvironmentOption, 0, len(byTag)) + for _, item := range byTag { + if len(item.descriptions) == 1 { + for description := range item.descriptions { + item.option.Description = description + } + } + item.option.Selected = item.option.Tag == selectedTag + result = append(result, item.option) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Tag < result[j].Tag + }) + return result, nil +} + +func normalizePermissionGrants(permissions []CreatePermissionRequest, submitted map[string]string) ([]perm_model.AccessMode, error) { + if len(permissions) == 0 { + return nil, nil + } + grants := make([]perm_model.AccessMode, len(permissions)) + for i, permission := range permissions { + if permission.Required { + grants[i] = permission.Mode + continue + } + var mode perm_model.AccessMode + switch submitted[permission.FormName] { + case "none": + mode = perm_model.AccessModeNone + case "read": + mode = perm_model.AccessModeRead + case "write": + mode = perm_model.AccessModeWrite + default: + return nil, fmt.Errorf("codespace permission confirmation is missing for %s %s", permission.RepositoryFullName, permission.UnitName) + } + if mode > permission.Mode { + return nil, fmt.Errorf("codespace permission confirmation exceeds the requested %s access for %s %s", permission.ModeName, permission.RepositoryFullName, permission.UnitName) + } + grants[i] = mode + } + return grants, nil +} + +func ensurePermissionAuthorization(ctx context.Context, userID, sourceRepoID int64, requestHash string, permissions []CreatePermissionRequest, grantedModes []perm_model.AccessMode) (int64, error) { + if len(permissions) == 0 { + return 0, nil + } + var authorizations []*codespace_model.PermissionAuthorization + err := db.GetEngine(ctx). + Where("user_id = ? AND source_repo_id = ? AND request_hash = ? AND revoked_unix = 0", userID, sourceRepoID, requestHash). + Find(&authorizations) + if err != nil { + return 0, err + } + for _, authorization := range authorizations { + var rules []*codespace_model.PermissionRepository + if err := db.GetEngine(ctx).Where("authorization_id = ?", authorization.ID).Asc("target_repo_id", "unit_type").Find(&rules); err != nil { + return 0, err + } + if len(rules) != len(permissions) { + continue + } + matches := true + for i, rule := range rules { + permission := permissions[i] + if rule.TargetRepoID != permission.RepositoryID || rule.UnitType != permission.UnitType || rule.RequestedMode != permission.Mode || rule.GrantedMode != grantedModes[i] { + matches = false + break + } + } + if matches { + return authorization.ID, nil + } + } + now := time.Now().Unix() + authorization := &codespace_model.PermissionAuthorization{ + UserID: userID, SourceRepoID: sourceRepoID, RequestHash: requestHash, + CreatedUnix: now, UpdatedUnix: now, + } + if _, err := db.GetEngine(ctx).Insert(authorization); err != nil { + return 0, err + } + rules := make([]*codespace_model.PermissionRepository, 0, len(permissions)) + for i, permission := range permissions { + rules = append(rules, &codespace_model.PermissionRepository{ + AuthorizationID: authorization.ID, + TargetRepoID: permission.RepositoryID, + UnitType: permission.UnitType, + RequestedMode: permission.Mode, + GrantedMode: grantedModes[i], + }) + } + if _, err := db.GetEngine(ctx).Insert(rules); err != nil { + return 0, err + } + return authorization.ID, nil +} + +func newCreateCodespaceRow(userID, repoID int64, environmentTag string, sourceRef *createSourceRef, devContainer *createDevContainerPlan, authorizationID int64) *codespace_model.Codespace { + now := time.Now().Unix() + codespaceUUID := codespace_model.NewUUID() + codespace := &codespace_model.Codespace{ + UUID: codespaceUUID, + UserID: userID, + RepoID: repoID, + RefType: sourceRef.Type, + RefName: sourceRef.StoredName, + EnvironmentTag: environmentTag, + CommitSHA: sourceRef.CommitSHA, + DevContainerPath: devContainer.Path, + DevContainerContentSHA256: devContainer.ContentSHA256, + DevContainerDefaultImage: devContainer.DefaultImage, + PermissionAuthorizationID: authorizationID, + Status: codespace_model.StatusCreating, + AutoStopMode: codespace_model.AutoStopModeDefault, + CreatedUnix: now, + UpdatedUnix: now, + } + codespace.OperationRVersion = 1 + codespace.OperationType = codespace_model.OperationCreate + codespace.OperationStatus = codespace_model.OperationStatusQueued + codespace.OperationTrigger = codespace_model.OperationTriggerUser + codespace.OperationCreatedUnix = now + return codespace +} + +func codespaceUserRelationLockKey(userID int64) string { + return fmt.Sprintf("codespace_user_%d", userID) +} diff --git a/services/codespace/create_test.go b/services/codespace/create_test.go new file mode 100644 index 0000000000000..d575b85fff7c8 --- /dev/null +++ b/services/codespace/create_test.go @@ -0,0 +1,441 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + git_model "gitea.dev/models/git" + "gitea.dev/models/perm" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unit" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + "gitea.dev/modules/git/gitcmd" + "gitea.dev/modules/json" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateCodespaceQueuesCreateWhenManagerMatches(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + insertServiceManagerWithTags(t, 2, "default") + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + result, err := createConfirmedCodespace(t, CreateCodespaceOptions{ + User: user, + Repo: repo, + RefType: "branch", + RefName: "master", + }) + require.NoError(t, err) + assert.Equal(t, codespace_model.StatusCreating, result.Status) + assert.Equal(t, "default", result.EnvironmentTag) + + row := loadServiceCodespace(t, result.CodespaceUUID) + assert.Equal(t, user.ID, row.UserID) + assert.Equal(t, repo.ID, row.RepoID) + assert.Equal(t, "branch", row.RefType) + assert.Equal(t, "master", row.RefName) + assert.NotEmpty(t, row.CommitSHA) + assert.Equal(t, setting.Codespace.DevContainerDefaultImage, row.DevContainerDefaultImage) + assert.Equal(t, codespace_model.OperationCreate, row.OperationType) + assert.Equal(t, codespace_model.OperationStatusQueued, row.OperationStatus) + assert.Equal(t, codespace_model.OperationTriggerUser, row.OperationTrigger) + assert.EqualValues(t, 1, row.OperationRVersion) +} + +func TestCreateCodespaceUsesCreatorManagerForOrganizationRepository(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + insertServiceManagerWithTags(t, 2, "default") + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + + result, err := createConfirmedCodespace(t, CreateCodespaceOptions{ + User: user, + Repo: repo, + RefType: "branch", + RefName: "master", + }) + require.NoError(t, err) + assert.Equal(t, codespace_model.StatusCreating, result.Status) + + row := loadServiceCodespace(t, result.CodespaceUUID) + assert.Equal(t, user.ID, row.UserID) + assert.Equal(t, repo.ID, row.RepoID) +} + +func TestCreateCodespaceRequiresAvailableEnvironment(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + plan, err := PrepareCodespace(t.Context(), CreateCodespaceOptions{User: user, Repo: repo}) + require.NoError(t, err) + assert.Empty(t, plan.Environments) + _, err = CreateCodespace(t.Context(), CreateCodespaceOptions{User: user, Repo: repo, RequestHash: plan.RequestHash}) + require.ErrorIs(t, err, ErrCreateEnvironmentUnavailable) +} + +func TestListVisibleCreateEnvironmentsMergesManagerDeclarations(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + site := insertServiceManagerWithTags(t, 0, "standard", "gpu") + markServiceManagerOnline(t, site, `[{"tag":"standard","description":"General development"},{"tag":"gpu","description":"Accelerated"}]`) + personal := insertServiceManagerWithTags(t, 2, "standard", "personal") + markServiceManagerOnline(t, personal, `[{"tag":"standard","description":"General development"},{"tag":"personal"}]`) + foreign := insertServiceManagerWithTags(t, 4, "foreign") + markServiceManagerOnline(t, foreign, `[{"tag":"foreign"}]`) + + environments, err := listVisibleCreateEnvironments(t.Context(), 2, "standard") + require.NoError(t, err) + require.Len(t, environments, 3) + assert.Equal(t, CreateEnvironmentOption{Tag: "gpu", Description: "Accelerated", Site: true, Online: true}, environments[0]) + assert.Equal(t, CreateEnvironmentOption{Tag: "personal", Personal: true, Online: true}, environments[1]) + assert.Equal(t, CreateEnvironmentOption{Tag: "standard", Description: "General development", Site: true, Personal: true, Online: true, Selected: true}, environments[2]) +} + +func TestCreateCodespaceRequiresExplicitEnvironmentSelection(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + insertServiceManagerWithTags(t, 2, "default") + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + plan, err := PrepareCodespace(t.Context(), CreateCodespaceOptions{User: user, Repo: repo}) + require.NoError(t, err) + + countBefore, err := db.GetEngine(t.Context()).Count(new(codespace_model.Codespace)) + require.NoError(t, err) + _, err = CreateCodespace(t.Context(), CreateCodespaceOptions{User: user, Repo: repo, RequestHash: plan.RequestHash}) + require.ErrorIs(t, err, ErrCreateEnvironmentUnavailable) + countAfter, err := db.GetEngine(t.Context()).Count(new(codespace_model.Codespace)) + require.NoError(t, err) + assert.Equal(t, countBefore, countAfter) +} + +func TestCreateCodespaceRejectsDisabledCodespace(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + t.Cleanup(test.MockVariableValue(&setting.Codespace.Enabled, false)) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + _, err := PrepareCodespace(t.Context(), CreateCodespaceOptions{User: user, Repo: repo}) + require.ErrorIs(t, err, ErrCreateStateUnavailable) +} + +func TestCreateCodespaceRequiresUserAndRepository(t *testing.T) { + for name, opts := range map[string]CreateCodespaceOptions{ + "user": {RequestHash: "confirmed", Repo: &repo_model.Repository{ID: 1}}, + "repository": {RequestHash: "confirmed", User: &user_model.User{ID: 1}}, + } { + t.Run(name, func(t *testing.T) { + _, err := CreateCodespace(t.Context(), opts) + require.Error(t, err) + }) + } +} + +func TestCreateCodespacePersistsPullRefAndValidatesGitProtocol(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureGitTransportTestSettings(t, codespace_model.GitProtocolSSH, false, false, []string{ + "gitea.example.com " + testGitSSHPublicKey, + }) + + insertServiceManagerWithTags(t, 0, "default") + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + result, err := createConfirmedCodespace(t, CreateCodespaceOptions{ + User: user, + Repo: repo, + RefType: "pull", + RefName: "3", + }) + require.NoError(t, err) + plan, err := PrepareCodespace(t.Context(), CreateCodespaceOptions{User: user, Repo: repo, RefType: "pull", RefName: "3"}) + require.NoError(t, err) + assert.Equal(t, "3", plan.RefName) + require.NotNil(t, plan.PullRequest) + assert.Equal(t, int64(3), plan.PullRequest.Index) + assert.Equal(t, "user2/repo1", plan.PullRequest.HeadRepoFullName) + assert.Equal(t, "branch2", plan.PullRequest.HeadBranch) + + row := loadServiceCodespace(t, result.CodespaceUUID) + assert.Equal(t, "pull", row.RefType) + assert.Equal(t, "refs/pull/3/head", row.RefName) + assert.NotEmpty(t, row.CommitSHA) +} + +func TestPrepareCodespaceRequiresForkPullSourceAccess(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + require.NoError(t, db.Insert(t.Context(), &git_model.Branch{RepoID: 11, Name: "branch2"})) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 11}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10}) + _, _, runErr := gitcmd.NewCommand("fast-import").WithRepo(repo.CodeStorageRepo()).WithStdinBytes([]byte(`commit refs/heads/master +committer user11 1714310400 +0000 +data < 1714310400 +0000 +data < 1714310400 +0000 +data < 0 { + opts.EnvironmentTag = plan.Environments[0].Tag + } + opts.PermissionGrants = make(map[string]string, len(plan.Permissions)) + for _, permission := range plan.Permissions { + opts.PermissionGrants[permission.FormName] = permission.ModeName + } + return CreateCodespace(t.Context(), opts) +} + +func insertServiceManagerWithTags(t *testing.T, userID int64, tags ...string) *codespace_model.Manager { + t.Helper() + environments := make([]ManagerEnvironmentDeclaration, 0, len(tags)) + for _, tag := range tags { + environments = append(environments, ManagerEnvironmentDeclaration{Tag: tag}) + } + tagsJSON, err := json.Marshal(environments) + require.NoError(t, err) + manager := &codespace_model.Manager{ + Name: "manager", + UserID: userID, + RuntimeState: codespace_model.ManagerRuntimeStateOnline, + TagsJSON: string(tagsJSON), + CreatedUnix: time.Now().Unix(), + LastOnlineUnix: time.Now().Unix(), + } + manager.GenerateManagerSecret() + require.NoError(t, db.Insert(t.Context(), manager)) + return manager +} diff --git a/services/codespace/devcontainer.go b/services/codespace/devcontainer.go new file mode 100644 index 0000000000000..a2ad0ee9f8909 --- /dev/null +++ b/services/codespace/devcontainer.go @@ -0,0 +1,348 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "path" + "slices" + "sort" + "strings" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" + user_model "gitea.dev/models/user" + "gitea.dev/modules/git" + "gitea.dev/modules/json" + "gitea.dev/modules/setting" + "gitea.dev/modules/util" + + "github.com/tailscale/hujson" +) + +const ( + devContainerPlatformDefaultSelection = "platform_default" + devContainerPrimaryPath = ".devcontainer/devcontainer.json" + devContainerRootPath = ".devcontainer.json" + maxDevContainerConfigurations = 32 + maxCodespacePermissionRepositories = 32 + maxCodespacePermissionRepositoryRules = 128 + maxRecommendedSecrets = 100 +) + +// CreateDevContainerOption describes one configuration available at the selected commit. +type CreateDevContainerOption struct { + Selection string + Name string + Path string + PlatformDefault bool + Selected bool +} + +// createDevContainerPlan contains the immutable runtime choice and confirmation data. +type createDevContainerPlan struct { + Path string + Name string + ContentSHA256 string + DefaultImage string + PermissionRepositories map[string]map[string]string + Permissions []CreatePermissionRequest + RecommendedSecrets []CreateRecommendedSecret +} + +type devContainerDocument struct { + Name string `json:"name"` + Secrets map[string]devContainerSecret `json:"secrets"` + Customizations devContainerCustomizations `json:"customizations"` +} + +type devContainerSecret struct { + Description string `json:"description"` +} + +type devContainerCustomizations struct { + Gitea devContainerGiteaCustomization `json:"gitea"` +} + +type devContainerGiteaCustomization struct { + Repositories map[string]devContainerRepositoryPermission `json:"repositories"` +} + +type devContainerRepositoryPermission struct { + Permissions map[string]string `json:"permissions"` +} + +func prepareCreateDevContainer(ctx context.Context, user *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, sourceRef *createSourceRef, selection string) (*createDevContainerPlan, []CreateDevContainerOption, error) { + commit, err := gitRepo.GetCommit(ctx, sourceRef.CommitSHA) + if err != nil { + return nil, nil, fmt.Errorf("load Dev Container commit: %w", err) + } + paths, err := discoverDevContainerPaths(ctx, gitRepo, commit) + if err != nil { + return nil, nil, err + } + + configs := make([]*createDevContainerPlan, 0, len(paths)) + for _, configPath := range paths { + config, err := loadRepositoryDevContainer(ctx, gitRepo, commit, configPath) + if err != nil { + return nil, nil, err + } + configs = append(configs, config) + } + + selection = strings.TrimSpace(selection) + if selection == "" { + selection = devContainerPlatformDefaultSelection + if slices.Contains(paths, devContainerPrimaryPath) { + selection = devContainerPrimaryPath + } else if slices.Contains(paths, devContainerRootPath) { + selection = devContainerRootPath + } + } + + selected := &createDevContainerPlan{ + DefaultImage: strings.TrimSpace(setting.Codespace.DevContainerDefaultImage), + } + if selection != devContainerPlatformDefaultSelection { + index := slices.IndexFunc(configs, func(config *createDevContainerPlan) bool { + return config.Path == selection + }) + if index < 0 { + return nil, nil, fmt.Errorf("Dev Container configuration %q is not available at commit %s", selection, sourceRef.CommitSHA) + } + selected = configs[index] + permissions, err := resolveCreatePermissions(ctx, user, repo, selected.PermissionRepositories) + if err != nil { + return nil, nil, err + } + selected.Permissions = permissions + } + + options := make([]CreateDevContainerOption, 0, len(configs)+1) + for _, config := range configs { + options = append(options, CreateDevContainerOption{ + Selection: config.Path, + Name: config.Name, + Path: config.Path, + Selected: selection == config.Path, + }) + } + options = append(options, CreateDevContainerOption{ + Selection: devContainerPlatformDefaultSelection, + PlatformDefault: true, + Selected: selection == devContainerPlatformDefaultSelection, + }) + return selected, options, nil +} + +func discoverDevContainerPaths(ctx context.Context, gitRepo *git.Repository, commit *git.Commit) ([]string, error) { + paths := make([]string, 0, 4) + for _, configPath := range []string{devContainerPrimaryPath, devContainerRootPath} { + entry, err := commit.GetTreeEntryByPath(ctx, gitRepo, configPath) + if err == nil { + if !entry.IsRegular() { + return nil, fmt.Errorf("Dev Container configuration %q must be a regular file", configPath) + } + paths = append(paths, configPath) + continue + } + if !git.IsErrNotExist(err) && !errors.Is(err, util.ErrNotExist) { + return nil, err + } + } + + root, err := commit.Tree().SubTree(ctx, gitRepo, ".devcontainer") + if err != nil { + if git.IsErrNotExist(err) || errors.Is(err, util.ErrNotExist) { + return paths, nil + } + return nil, err + } + entries, err := root.ListEntries(ctx, gitRepo) + if err != nil { + return nil, err + } + slices.SortFunc(entries, func(a, b *git.TreeEntry) int { + return strings.Compare(a.Name(), b.Name()) + }) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + configPath := path.Join(".devcontainer", entry.Name(), "devcontainer.json") + configEntry, err := commit.GetTreeEntryByPath(ctx, gitRepo, configPath) + if err != nil { + if git.IsErrNotExist(err) || errors.Is(err, util.ErrNotExist) { + continue + } + return nil, err + } + if !configEntry.IsRegular() { + return nil, fmt.Errorf("Dev Container configuration %q must be a regular file", configPath) + } + paths = append(paths, configPath) + if len(paths) > maxDevContainerConfigurations { + return nil, fmt.Errorf("repository has more than %d Dev Container configurations", maxDevContainerConfigurations) + } + } + return paths, nil +} + +func loadRepositoryDevContainer(ctx context.Context, gitRepo *git.Repository, commit *git.Commit, configPath string) (*createDevContainerPlan, error) { + blob, err := commit.GetBlobByPath(ctx, gitRepo, configPath) + if err != nil { + return nil, err + } + if blob.Size(ctx) > setting.Codespace.DevContainerConfigMaxSize { + return nil, fmt.Errorf("Dev Container configuration %q exceeds %d bytes", configPath, setting.Codespace.DevContainerConfigMaxSize) + } + content, err := blob.GetBlobBytes(ctx, setting.Codespace.DevContainerConfigMaxSize+1) + if err != nil { + return nil, err + } + if int64(len(content)) > setting.Codespace.DevContainerConfigMaxSize { + return nil, fmt.Errorf("Dev Container configuration %q exceeds %d bytes", configPath, setting.Codespace.DevContainerConfigMaxSize) + } + sum := sha256.Sum256(content) + standard, err := hujson.Standardize(content) + if err != nil { + return nil, fmt.Errorf("parse Dev Container configuration %q: %w", configPath, err) + } + var document devContainerDocument + if err := json.Unmarshal(standard, &document); err != nil { + return nil, fmt.Errorf("parse Dev Container configuration %q: %w", configPath, err) + } + name := strings.TrimSpace(document.Name) + if name == "" { + name = configPath + } + repositories, err := devContainerPermissionRepositories(document.Customizations) + if err != nil { + return nil, fmt.Errorf("parse Dev Container configuration %q: %w", configPath, err) + } + recommendedSecrets, err := parseRecommendedSecrets(document.Secrets) + if err != nil { + return nil, fmt.Errorf("parse Dev Container configuration %q: %w", configPath, err) + } + return &createDevContainerPlan{ + Path: configPath, + Name: name, + ContentSHA256: hex.EncodeToString(sum[:]), + PermissionRepositories: repositories, + RecommendedSecrets: recommendedSecrets, + }, nil +} + +func parseRecommendedSecrets(configured map[string]devContainerSecret) ([]CreateRecommendedSecret, error) { + if len(configured) > maxRecommendedSecrets { + return nil, fmt.Errorf("Dev Container recommends more than %d secrets", maxRecommendedSecrets) + } + secrets := make([]CreateRecommendedSecret, 0, len(configured)) + seen := make(map[string]struct{}, len(configured)) + for rawName, configuredSecret := range configured { + name := strings.ToUpper(strings.TrimSpace(rawName)) + if err := validateUserSecretName(name); err != nil { + return nil, fmt.Errorf("invalid recommended secret %q: %w", rawName, err) + } + if _, exists := seen[name]; exists { + return nil, fmt.Errorf("duplicate recommended secret %q", name) + } + seen[name] = struct{}{} + secrets = append(secrets, CreateRecommendedSecret{Name: name, Description: strings.TrimSpace(configuredSecret.Description)}) + } + sort.Slice(secrets, func(i, j int) bool { return secrets[i].Name < secrets[j].Name }) + return secrets, nil +} + +func resolveCreateSecrets(ctx context.Context, userID, repoID int64, recommendations []CreateRecommendedSecret) ([]CreateRecommendedSecret, []CreateSecretSummary, error) { + var secrets []*codespace_model.UserSecret + if err := db.GetEngine(ctx).Cols("id", "name", "all_repositories").Where("user_id = ?", userID).Asc("name").Find(&secrets); err != nil { + return nil, nil, err + } + byName := make(map[string]*codespace_model.UserSecret, len(secrets)) + secretIDs := make([]int64, 0, len(secrets)) + for _, secret := range secrets { + byName[secret.Name] = secret + secretIDs = append(secretIDs, secret.ID) + } + availableSecretIDs := make(map[int64]struct{}, len(secrets)) + if len(secretIDs) > 0 { + var bindings []*codespace_model.UserSecretRepository + if err := db.GetEngine(ctx).In("secret_id", secretIDs).Where("repo_id = ?", repoID).Find(&bindings); err != nil { + return nil, nil, err + } + for _, binding := range bindings { + availableSecretIDs[binding.SecretID] = struct{}{} + } + } + for i := range recommendations { + secret := byName[recommendations[i].Name] + if secret == nil { + continue + } + recommendations[i].Configured = true + _, selected := availableSecretIDs[secret.ID] + recommendations[i].Available = secret.AllRepositories || selected + } + descriptions := make(map[string]string, len(recommendations)) + for _, recommendation := range recommendations { + descriptions[recommendation.Name] = recommendation.Description + } + available := make([]CreateSecretSummary, 0, len(secrets)) + for _, secret := range secrets { + _, selected := availableSecretIDs[secret.ID] + if secret.AllRepositories || selected { + available = append(available, CreateSecretSummary{Name: secret.Name, Description: descriptions[secret.Name]}) + } + } + return recommendations, available, nil +} + +func devContainerPermissionRepositories(customizations devContainerCustomizations) (map[string]map[string]string, error) { + if len(customizations.Gitea.Repositories) > maxCodespacePermissionRepositories { + return nil, fmt.Errorf("Gitea customization requests more than %d repositories", maxCodespacePermissionRepositories) + } + repositories := make(map[string]map[string]string, len(customizations.Gitea.Repositories)) + ruleCount := 0 + for name, repository := range customizations.Gitea.Repositories { + name = strings.TrimSpace(name) + ownerName, repoName, ok := strings.Cut(name, "/") + if !ok || ownerName == "" || repoName == "" || strings.Contains(repoName, "/") { + return nil, fmt.Errorf("invalid Gitea permission repository %q", name) + } + if _, exists := repositories[name]; exists { + return nil, fmt.Errorf("duplicate Gitea permission repository %q", name) + } + if len(repository.Permissions) == 0 { + return nil, fmt.Errorf("Gitea permission repository %q must contain permissions", name) + } + ruleCount += len(repository.Permissions) + if ruleCount > maxCodespacePermissionRepositoryRules { + return nil, fmt.Errorf("Gitea customization requests more than %d repository permissions", maxCodespacePermissionRepositoryRules) + } + permissions := make(map[string]string, len(repository.Permissions)) + for unitName, modeName := range repository.Permissions { + unitName = strings.ToLower(strings.TrimSpace(unitName)) + modeName = strings.ToLower(strings.TrimSpace(modeName)) + if _, exists := permissions[unitName]; exists { + return nil, fmt.Errorf("duplicate Gitea permission unit %q for %q", unitName, name) + } + if _, ok := codespacePermissionUnits[unitName]; !ok { + return nil, fmt.Errorf("unsupported Gitea permission unit %q", unitName) + } + if modeName != "read" && modeName != "write" { + return nil, fmt.Errorf("Gitea permission %q for %q must be read or write", unitName, name) + } + permissions[unitName] = modeName + } + repositories[name] = permissions + } + return repositories, nil +} diff --git a/services/codespace/disabled_test.go b/services/codespace/disabled_test.go new file mode 100644 index 0000000000000..4605171758e2e --- /dev/null +++ b/services/codespace/disabled_test.go @@ -0,0 +1,154 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGatewayAndRuntimeRPCsRejectDisabledCodespace(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + t.Cleanup(test.MockVariableValue(&setting.Codespace.Enabled, true)) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + insertServiceManagerGatewayAddress(t, manager, "https://gateway.example.com") + insertServiceUserSSHKey(t, 1, testGitSSHPublicKey) + + runningUUID := "91919191-9191-4919-8919-919191919191" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: runningUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 91, + InteractionGeneration: 5, + }) + require.NoError(t, putRuntimeMetadataEntry(runningUUID, serviceRuntimeMetadataEntry(t, 91, []map[string]any{ + {"endpoint_id": "app-3000", "label": "App", "public": true}, + {"endpoint_id": "private-api", "label": "API", "public": false}, + }))) + issued, err := openEndpoint(t.Context(), OpenEndpointOptions{ + UserID: 1, + CodespaceUUID: runningUUID, + EndpointID: "private-api", + }) + require.NoError(t, err) + assert.EqualValues(t, 6, loadServiceCodespace(t, runningUUID).InteractionGeneration) + + restoreEnabled := test.MockVariableValue(&setting.Codespace.Enabled, false) + + publicResult, err := ValidatePublicEndpoint(t.Context(), manager, ValidatePublicEndpointOptions{ + CodespaceUUID: runningUUID, + EndpointID: "app-3000", + }) + require.NoError(t, err) + assert.Equal(t, PublicEndpointDeniedStateUnavailable, publicResult.GetDenied().GetCategory()) + + sessionResult, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ + Session: &codespacev1.RevalidateGatewaySessionRequest_Endpoint{Endpoint: &codespacev1.EndpointSessionBinding{ + UserId: 1, CodespaceUuid: runningUUID, EndpointId: "private-api", + }}, + }) + require.NoError(t, err) + assert.Equal(t, SessionDeniedStateUnavailable, sessionResult.GetDenied().GetCategory()) + + sshResult, err := VerifySSHPublicKey(t.Context(), manager, VerifySSHPublicKeyOptions{ + CodespaceUUID: runningUUID, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + assert.Equal(t, SSHAuthDeniedStateUnavailable, sshResult.GetDenied().GetCategory()) + + _, err = OpenEndpoint(t.Context(), OpenEndpointOptions{ + UserID: 1, + CodespaceUUID: runningUUID, + EndpointID: "private-api", + }) + require.ErrorIs(t, err, ErrOpenEndpointUnavailable) + + openResult, err := ValidateOpenToken(t.Context(), manager, ValidateOpenTokenOptions{Code: issued.code}) + require.NoError(t, err) + assert.Equal(t, OpenTokenDeniedStateUnavailable, openResult.GetDenied().GetCategory()) + assert.EqualValues(t, 6, loadServiceCodespace(t, runningUUID).InteractionGeneration) + + creatingUUID := "92929292-9292-4929-8929-929292929292" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: creatingUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 92, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + err = ReportRuntimeMetadata(t.Context(), manager, ReportRuntimeMetadataOptions{ + CodespaceUUID: creatingUUID, + Metadata: serviceRuntimeMetadataProto(t, 92, "ready", nil), + MetadataGeneration: 1, + }) + require.ErrorIs(t, err, ErrRuntimeMetadataStateUnavailable) + hasReady, err := HasReadyRuntimeMetadata(t.Context(), creatingUUID, 92) + require.NoError(t, err) + assert.False(t, hasReady) + + _, err = ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: creatingUUID, + OperationRVersion: 92, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.ErrorIs(t, err, ErrRequestRuntimeAccessStateUnavailable) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", creatingUUID) + + restoreEnabled() + + allowedOpen, err := ValidateOpenToken(t.Context(), manager, ValidateOpenTokenOptions{Code: issued.code}) + require.NoError(t, err) + assert.NotNil(t, allowedOpen.GetAllowed()) + assert.EqualValues(t, 7, allowedOpen.GetAllowed().GetInteractionGeneration()) +} + +func TestDisabledCodespaceRejectsStartupEntrypoints(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + stoppedUUID := "93939393-9393-4939-8939-939393939391" + runningUUID := "93939393-9393-4939-8939-939393939392" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: stoppedUUID, + Status: codespace_model.StatusStopped, + OperationRVersion: 93, + }) + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: runningUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 94, + InteractionGeneration: 1, + }) + token, err := GetOrCreateRegistrationToken(t.Context(), ManagerSettingsOptions{Scope: ManagerSettingsScopeSite}) + require.NoError(t, err) + + t.Cleanup(test.MockVariableValue(&setting.Codespace.Enabled, false)) + + _, err = ResumeCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: stoppedUUID}) + require.ErrorIs(t, err, ErrLifecycleActionStateUnavailable) + assert.Empty(t, loadServiceCodespace(t, stoppedUUID).OperationType) + + _, err = ContinueCodespace(t.Context(), ContinueCodespaceOptions{UserID: 1, CodespaceUUID: runningUUID}) + require.ErrorIs(t, err, ErrInteractionStateUnavailable) + assert.EqualValues(t, 1, loadServiceCodespace(t, runningUUID).InteractionGeneration) + + _, _, err = RegisterManager(t.Context(), token) + require.ErrorIs(t, err, ErrRegistrationStateUnavailable) +} diff --git a/services/codespace/fetch.go b/services/codespace/fetch.go new file mode 100644 index 0000000000000..544fe9ef5a400 --- /dev/null +++ b/services/codespace/fetch.go @@ -0,0 +1,694 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + git_model "gitea.dev/models/git" + issues_model "gitea.dev/models/issues" + perm_model "gitea.dev/models/perm" + access_model "gitea.dev/models/perm/access" + repo_model "gitea.dev/models/repo" + unit_model "gitea.dev/models/unit" + user_model "gitea.dev/models/user" + "gitea.dev/modules/globallock" + "gitea.dev/modules/json" + "gitea.dev/modules/setting" +) + +const ( + fetchMaxOperations = 256 + fetchMaxObservedOperations = 10000 + fetchMaxQueuedCandidates = 1024 +) + +var ( + // ErrFetchStateHistoryConflict is returned when observed operation history is ahead of Gitea. + ErrFetchStateHistoryConflict = errors.New("codespace operation history conflict") + // ErrFetchManagerUnavailable is returned when the Manager is not currently online. + ErrFetchManagerUnavailable = errors.New("codespace manager unavailable") +) + +// FetchOperationsOptions contains one Manager operation fetch request. +type FetchOperationsOptions struct { + StartupCapacityAvailable int32 + AcceptedOperationTypes []codespacev1.AcceptedOperationType + AcceptedCreateTags []string + ObservedOperations []*codespacev1.ObservedOperation + CleanupCapacityAvailable int32 +} + +// RuntimeSettings contains the effective runtime policy sent to Manager. +type RuntimeSettings struct { + AutoStopEnabled bool + IdleTimeoutSeconds int64 + InteractionGeneration int64 +} + +// FetchOperations renews observed operations and claims queued operations for one Manager. +func FetchOperations(ctx context.Context, manager *codespace_model.Manager, opts FetchOperationsOptions) (*codespacev1.FetchOperationsResponse, error) { + if manager == nil || manager.ID <= 0 { + return nil, errors.New("manager is required") + } + if err := validateFetchOptions(opts); err != nil { + return nil, err + } + + var result *codespacev1.FetchOperationsResponse + var summaries []*internalStateSummary + err := globallock.LockAndDo(ctx, fetchManagerLockKey(manager.ID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + currentManager, err := loadCodespaceManager(ctx, manager.ID) + if err != nil { + return err + } + if currentManager.RuntimeState != codespace_model.ManagerRuntimeStateOnline || isManagerOffline(currentManager) { + return ErrFetchManagerUnavailable + } + managerEnvironments, err := decodeManagerEnvironments(currentManager) + if err != nil { + return err + } + acceptedCreateTags, err := normalizeAcceptedCreateTags(opts.AcceptedCreateTags, managerEnvironments) + if err != nil { + return err + } + result = &codespacev1.FetchOperationsResponse{} + observedVersions, err := validateObservedOperationHistory(ctx, currentManager.ID, opts.ObservedOperations) + if err != nil { + return err + } + maxOperations := max(int32(1), min(fetchMaxOperations, opts.StartupCapacityAvailable+opts.CleanupCapacityAvailable)) + if err := appendRunningOperations(ctx, currentManager.ID, observedVersions, maxOperations, result, &summaries); err != nil { + return err + } + if int32(len(result.Operations)) >= maxOperations { + return nil + } + grantTime := time.Now() + remaining := int(maxOperations) - len(result.Operations) + if opts.CleanupCapacityAvailable > 0 { + claimed, err := claimQueuedOperations(ctx, currentManager.ID, currentManager.UserID, grantTime, remaining, int(opts.CleanupCapacityAvailable), nil, []string{codespace_model.OperationStop, codespace_model.OperationDelete}, result, &summaries) + if err != nil { + return err + } + remaining -= claimed + } + if remaining <= 0 || opts.StartupCapacityAvailable <= 0 || !setting.Codespace.Enabled { + return nil + } + capacity := int(opts.StartupCapacityAvailable) + if slices.Contains(opts.AcceptedOperationTypes, codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_CREATE) && len(acceptedCreateTags) > 0 { + claimed, err := claimQueuedOperations(ctx, currentManager.ID, currentManager.UserID, grantTime, remaining, capacity, acceptedCreateTags, []string{codespace_model.OperationCreate}, result, &summaries) + if err != nil { + return err + } + remaining -= claimed + capacity -= claimed + } + if remaining > 0 && capacity > 0 && slices.Contains(opts.AcceptedOperationTypes, codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_RESUME) { + _, err = claimQueuedOperations(ctx, currentManager.ID, currentManager.UserID, grantTime, remaining, capacity, nil, []string{codespace_model.OperationResume}, result, &summaries) + } + return err + }) + }) + if err != nil { + return nil, err + } + for _, summary := range summaries { + appendInternalStateSummary(ctx, summary) + } + return result, nil +} + +func validateFetchOptions(opts FetchOperationsOptions) error { + if opts.StartupCapacityAvailable < 0 || opts.StartupCapacityAvailable > 10000 { + return errors.New("startup_capacity_available must be between 0 and 10000") + } + if opts.CleanupCapacityAvailable < 0 || opts.CleanupCapacityAvailable > 256 { + return errors.New("cleanup_capacity_available must be between 0 and 256") + } + if len(opts.AcceptedCreateTags) > managerMaxEnvironments { + return errors.New("accepted_create_tags exceeds 64") + } + if len(opts.ObservedOperations) > fetchMaxObservedOperations { + return errors.New("observed_operations exceeds 10000") + } + seen := make(map[string]struct{}, len(opts.ObservedOperations)) + for _, observed := range opts.ObservedOperations { + if observed == nil { + return errors.New("observed operation is required") + } + if err := codespace_model.ValidateUUID(observed.GetCodespaceUuid()); err != nil { + return err + } + if observed.GetOperationRversion() <= 0 { + return errors.New("observed operation_rversion must be positive") + } + if _, ok := seen[observed.GetCodespaceUuid()]; ok { + return errors.New("observed_operations contains duplicate codespace uuid") + } + seen[observed.GetCodespaceUuid()] = struct{}{} + } + for _, acceptedType := range opts.AcceptedOperationTypes { + if acceptedType != codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_CREATE && acceptedType != codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_RESUME { + return fmt.Errorf("invalid accepted operation type %d", acceptedType) + } + } + return nil +} + +func loadCodespaceManager(ctx context.Context, managerID int64) (*codespace_model.Manager, error) { + manager := new(codespace_model.Manager) + has, err := db.GetEngine(ctx).ID(managerID).Get(manager) + if err != nil { + return nil, err + } + if !has { + return nil, ErrFetchManagerUnavailable + } + return manager, nil +} + +func currentManagerAllowsOnlineOrRecovering(ctx context.Context, managerID int64) (bool, error) { + currentManager, err := loadCodespaceManager(ctx, managerID) + if err != nil { + if errors.Is(err, ErrFetchManagerUnavailable) { + return false, nil + } + return false, err + } + return managerAllowsOnlineOrRecovering(currentManager), nil +} + +func managerAllowsOnlineOrRecovering(manager *codespace_model.Manager) bool { + switch manager.RuntimeState { + case codespace_model.ManagerRuntimeStateOnline, codespace_model.ManagerRuntimeStateRecovering: + return !isManagerOffline(manager) + default: + return false + } +} + +func decodeManagerEnvironments(manager *codespace_model.Manager) ([]ManagerEnvironmentDeclaration, error) { + var environments []ManagerEnvironmentDeclaration + if err := json.Unmarshal([]byte(manager.TagsJSON), &environments); err != nil { + return nil, fmt.Errorf("decode manager environments: %w", err) + } + return environments, nil +} + +func normalizeAcceptedCreateTags(tags []string, declared []ManagerEnvironmentDeclaration) ([]string, error) { + declaredTags := make(map[string]struct{}, len(declared)) + for _, environment := range declared { + declaredTags[environment.Tag] = struct{}{} + } + normalized := make([]string, 0, len(tags)) + seen := make(map[string]struct{}, len(tags)) + for _, tag := range tags { + tag = strings.ToLower(strings.TrimSpace(tag)) + if !tagPattern.MatchString(tag) { + return nil, fmt.Errorf("invalid accepted create tag %q", tag) + } + if _, ok := declaredTags[tag]; !ok { + return nil, fmt.Errorf("accepted create tag %q is not declared", tag) + } + if _, ok := seen[tag]; ok { + continue + } + seen[tag] = struct{}{} + normalized = append(normalized, tag) + } + return normalized, nil +} + +func isManagerOffline(manager *codespace_model.Manager) bool { + return manager.LastOnlineUnix <= 0 || time.Now().Unix()-manager.LastOnlineUnix > int64(setting.Codespace.ManagerOfflineTimeout/time.Second) +} + +func validateObservedOperationHistory(ctx context.Context, managerID int64, observed []*codespacev1.ObservedOperation) (map[string]int64, error) { + observedVersions := make(map[string]int64, len(observed)) + for _, item := range observed { + observedVersions[item.GetCodespaceUuid()] = item.GetOperationRversion() + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(item.GetCodespaceUuid()).Get(codespace) + if err != nil { + return nil, err + } + if !has || codespace.ManagerID != managerID { + continue + } + if item.GetOperationRversion() > codespace.OperationRVersion { + return nil, ErrFetchStateHistoryConflict + } + } + return observedVersions, nil +} + +func appendRunningOperations(ctx context.Context, managerID int64, observedVersions map[string]int64, maxOperations int32, result *codespacev1.FetchOperationsResponse, summaries *[]*internalStateSummary) error { + var rows []*codespace_model.Codespace + if err := db.GetEngine(ctx). + Where("manager_id = ? AND operation_status = ?", managerID, codespace_model.OperationStatusRunning). + Asc("operation_created_unix", "uuid"). + Find(&rows); err != nil { + return err + } + grantTime := time.Now() + for _, codespace := range rows { + leaseMillis, deadlineUnix, ok := grantLease(codespace.OperationStartedUnix, grantTime) + if !ok { + summary := operationTimeoutSummary(codespace, timeoutStatus(codespace.OperationType)) + if err := applyRunningTimeout(ctx, codespace, grantTime.Unix()); err != nil { + return err + } + *summaries = append(*summaries, summary) + continue + } + observedVersion, hasObserved := observedVersions[codespace.UUID] + if !hasObserved { + continue + } + if observedVersion > codespace.OperationRVersion { + return ErrFetchStateHistoryConflict + } + if !setting.Codespace.Enabled && isStartupOperation(codespace.OperationType) { + if int32(len(result.Operations)) >= maxOperations { + continue + } + result.Operations = append(result.Operations, buildAbortOperationPayload(codespace)) + continue + } + if _, err := db.GetEngine(ctx).ID(codespace.UUID).Cols("operation_deadline_unix").Update(&codespace_model.Codespace{OperationDeadlineUnix: deadlineUnix}); err != nil { + return err + } + if observedVersion == codespace.OperationRVersion { + result.RenewedLeases = append(result.RenewedLeases, &codespacev1.RenewedOperationLease{ + CodespaceUuid: codespace.UUID, + OperationRversion: codespace.OperationRVersion, + LeaseValidForMilliseconds: leaseMillis, + }) + continue + } + if int32(len(result.Operations)) >= maxOperations { + continue + } + payload, err := buildOperationPayload(ctx, codespace, leaseMillis) + if err != nil { + return err + } + result.Operations = append(result.Operations, payload) + } + return nil +} + +func isStartupOperation(operationType string) bool { + return operationType == codespace_model.OperationCreate || operationType == codespace_model.OperationResume +} + +func claimQueuedOperations(ctx context.Context, managerID, managerUserID int64, grantTime time.Time, remaining, capacity int, createTags, operationTypes []string, result *codespacev1.FetchOperationsResponse, summaries *[]*internalStateSummary) (int, error) { + if remaining <= 0 || capacity <= 0 { + return 0, nil + } + limit := min(fetchMaxQueuedCandidates, remaining*4, capacity*4) + var candidates []*codespace_model.Codespace + query := db.GetEngine(ctx). + Where("operation_status = ?", codespace_model.OperationStatusQueued). + In("operation_type", operationTypes). + In("status", queuedOperationCandidateStatuses(operationTypes)). + Asc("operation_created_unix", "uuid"). + Limit(limit) + if createTags == nil { + query = query.And("manager_id = ?", managerID) + } else { + query = query.And("manager_id = ? AND repo_id > ?", 0, 0).In("environment_tag", createTags) + if managerUserID > 0 { + query = query.And("user_id = ?", managerUserID) + } + } + if err := query.Find(&candidates); err != nil { + return 0, err + } + claimed := 0 + for _, candidate := range candidates { + if claimed >= capacity || claimed >= remaining { + break + } + if isQueuedExpired(candidate, grantTime) { + summary := operationTimeoutSummary(candidate, queuedTimeoutStatus(candidate.OperationType)) + if err := applyQueuedTimeout(ctx, candidate, grantTime.Unix()); err != nil { + return claimed, err + } + *summaries = append(*summaries, summary) + continue + } + startedUnix := grantTime.Unix() + leaseMillis, deadlineUnix, ok := grantLease(startedUnix, grantTime) + if !ok { + continue + } + affected, err := claimQueuedOperation(ctx, candidate, managerID, managerUserID, startedUnix, deadlineUnix) + if err != nil { + return claimed, err + } + if affected == 0 { + continue + } + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(candidate.UUID).Get(codespace) + if err != nil { + return claimed, err + } + if !has || !isCurrentRunningOperation(codespace, managerID, candidate.OperationRVersion) { + continue + } + payload, err := buildOperationPayload(ctx, codespace, leaseMillis) + if err != nil { + return claimed, err + } + result.Operations = append(result.Operations, payload) + claimed++ + } + return claimed, nil +} + +func claimQueuedOperation(ctx context.Context, candidate *codespace_model.Codespace, managerID, managerUserID, startedUnix, deadlineUnix int64) (int64, error) { + updates := &codespace_model.Codespace{ + ManagerID: managerID, + OperationStatus: codespace_model.OperationStatusRunning, + OperationStartedUnix: startedUnix, + OperationDeadlineUnix: deadlineUnix, + } + query := db.GetEngine(ctx). + Where("uuid = ? AND operation_r_version = ? AND operation_type = ? AND operation_status = ? AND operation_trigger = ?", + candidate.UUID, candidate.OperationRVersion, candidate.OperationType, codespace_model.OperationStatusQueued, candidate.OperationTrigger) + if candidate.OperationType == codespace_model.OperationCreate { + query = query.And("status = ? AND manager_id = ? AND environment_tag = ? AND repo_id = ?", codespace_model.StatusCreating, 0, candidate.EnvironmentTag, candidate.RepoID) + if managerUserID > 0 { + query = query.And("user_id = ?", managerUserID) + } + } else { + query = query.And("manager_id = ?", managerID) + } + return query.Cols("manager_id", "operation_status", "operation_started_unix", "operation_deadline_unix").Update(updates) +} + +func queuedOperationCandidateStatuses(operationTypes []string) []string { + statuses := make([]string, 0, len(operationTypes)) + for _, operationType := range operationTypes { + switch operationType { + case codespace_model.OperationCreate: + statuses = append(statuses, codespace_model.StatusCreating) + case codespace_model.OperationResume: + statuses = append(statuses, codespace_model.StatusStopped) + case codespace_model.OperationStop: + statuses = append(statuses, codespace_model.StatusRunning) + case codespace_model.OperationDelete: + statuses = append(statuses, codespace_model.StatusDeleting) + } + } + return statuses +} + +func isQueuedExpired(codespace *codespace_model.Codespace, now time.Time) bool { + return codespace.OperationCreatedUnix > 0 && now.Unix() >= codespace.OperationCreatedUnix+int64(setting.Codespace.QueueTimeout/time.Second) +} + +func applyQueuedTimeout(ctx context.Context, codespace *codespace_model.Codespace, now int64) error { + return applyFinalState(ctx, codespace, queuedTimeoutStatus(codespace.OperationType), now) +} + +func queuedTimeoutStatus(operationType string) string { + switch operationType { + case codespace_model.OperationResume: + return codespace_model.StatusStopped + case codespace_model.OperationStop: + return codespace_model.StatusRunning + default: + return codespace_model.StatusFailed + } +} + +func applyRunningTimeout(ctx context.Context, codespace *codespace_model.Codespace, now int64) error { + return applyFinalState(ctx, codespace, timeoutStatus(codespace.OperationType), now) +} + +func grantLease(operationStartedUnix int64, grantTime time.Time) (int64, int64, bool) { + if operationStartedUnix <= 0 { + operationStartedUnix = grantTime.Unix() + } + totalDeadline := time.Unix(operationStartedUnix, 0).Add(setting.Codespace.OperationMaxDuration) + remaining := totalDeadline.Sub(grantTime) + if remaining < time.Millisecond { + return 0, 0, false + } + lease := min(remaining, setting.Codespace.OperationLeaseTimeout) + leaseMillis := lease.Milliseconds() + leaseDeadlineUnix := ceilUnix(grantTime.Add(lease)) + totalDeadlineUnix := totalDeadline.Unix() + deadlineUnix := min(totalDeadlineUnix, leaseDeadlineUnix) + return leaseMillis, deadlineUnix, leaseMillis > 0 +} + +func ceilUnix(t time.Time) int64 { + unix := t.Unix() + if t.After(time.Unix(unix, 0)) { + return unix + 1 + } + return unix +} + +func buildOperationPayload(ctx context.Context, codespace *codespace_model.Codespace, leaseMillis int64) (*codespacev1.OperationPayload, error) { + payload := &codespacev1.OperationPayload{ + OperationRversion: codespace.OperationRVersion, + CodespaceUuid: codespace.UUID, + LogOffset: codespace.LogSize, + LeaseValidForMilliseconds: leaseMillis, + } + switch codespace.OperationType { + case codespace_model.OperationCreate: + create, err := buildCreatePayload(ctx, codespace) + if err != nil { + return nil, err + } + payload.Command = &codespacev1.OperationPayload_Create{Create: create} + case codespace_model.OperationResume: + payload.Command = &codespacev1.OperationPayload_Resume{Resume: &codespacev1.ResumeOperationPayload{ + RuntimeSettings: runtimeSettingsMessage(effectiveRuntimeSettings(codespace)), + }} + case codespace_model.OperationStop: + payload.Command = &codespacev1.OperationPayload_Stop{Stop: &codespacev1.StopOperationPayload{}} + case codespace_model.OperationDelete: + payload.Command = &codespacev1.OperationPayload_Delete{Delete: &codespacev1.DeleteOperationPayload{}} + default: + return nil, fmt.Errorf("unsupported operation type %q", codespace.OperationType) + } + return payload, nil +} + +func buildAbortOperationPayload(codespace *codespace_model.Codespace) *codespacev1.OperationPayload { + payload := &codespacev1.OperationPayload{ + OperationRversion: codespace.OperationRVersion, + CodespaceUuid: codespace.UUID, + LogOffset: codespace.LogSize, + } + if codespace.OperationType == codespace_model.OperationResume { + payload.Command = &codespacev1.OperationPayload_AbortResume{AbortResume: &codespacev1.AbortResumeOperationPayload{}} + } else { + payload.Command = &codespacev1.OperationPayload_AbortCreate{AbortCreate: &codespacev1.AbortCreateOperationPayload{}} + } + return payload +} + +func buildCreatePayload(ctx context.Context, codespace *codespace_model.Codespace) (*codespacev1.CreateOperationPayload, error) { + repository, err := repo_model.GetRepositoryByID(ctx, codespace.RepoID) + if err != nil { + return nil, err + } + codespaceOwner, err := user_model.GetUserByID(ctx, codespace.UserID) + if err != nil { + return nil, err + } + gitProtocol, err := createGitProtocol() + if err != nil { + return nil, err + } + cloneRepository := repository + startRef, err := canonicalStartRef(codespace.RefType, codespace.RefName) + if err != nil { + return nil, err + } + if codespace.RefType == "pull" { + index, err := pullIndexFromCanonicalRef(codespace.RefName) + if err != nil { + return nil, err + } + pr, err := issues_model.GetPullRequestByIndex(ctx, repository.ID, index) + if err != nil { + return nil, err + } + if pr.BaseRepoID != repository.ID { + return nil, errors.New("persisted pull request base repository mismatch") + } + if !pr.IsAgitFlow() { + if err := pr.LoadHeadRepo(ctx); err != nil { + return nil, err + } + if pr.HeadRepo == nil { + return nil, errors.New("pull request head repository not found") + } + if err := validateCreateRepository(pr.HeadRepo); err != nil { + return nil, err + } + exists, err := git_model.IsBranchExist(ctx, pr.HeadRepo.ID, pr.HeadBranch) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.New("pull request head branch not found") + } + canRead, err := access_model.HasAccessUnit(ctx, codespaceOwner, pr.HeadRepo, unit_model.TypeCode, perm_model.AccessModeRead) + if err != nil { + return nil, err + } + if !canRead { + return nil, ErrCreatePermissionDenied + } + cloneRepository = pr.HeadRepo + startRef = "refs/heads/" + pr.HeadBranch + } + } + cloneLink := cloneRepository.CloneLinkGeneral(ctx) + capabilities, err := resolveGitTransportCapabilities(gitProtocol) + if err != nil { + return nil, err + } + httpCloneURL := cloneLink.HTTPS + if !capabilities.HTTPEnabled { + httpCloneURL = "" + } + sshCloneURL := cloneLink.SSH + if !capabilities.SSHEnabled { + sshCloneURL = "" + } + gitProtocolValue, err := gitProtocolMessage(gitProtocol) + if err != nil { + return nil, err + } + devContainer := &codespacev1.DevContainerConfiguration{ + RepositoryPath: codespace.DevContainerPath, + RepositoryContentSha256: codespace.DevContainerContentSHA256, + DefaultImage: codespace.DevContainerDefaultImage, + } + repositoryConfig := devContainer.RepositoryPath != "" || devContainer.RepositoryContentSha256 != "" + defaultConfig := devContainer.DefaultImage != "" + if repositoryConfig == defaultConfig || (repositoryConfig && (devContainer.RepositoryPath == "" || devContainer.RepositoryContentSha256 == "")) { + return nil, errors.New("invalid persisted Dev Container configuration") + } + return &codespacev1.CreateOperationPayload{ + RepoFullName: repository.FullName(), + RepoCloneHttpUrl: httpCloneURL, + RepoCloneSshUrl: sshCloneURL, + GitProtocol: gitProtocolValue, + StartRef: startRef, + CommitSha: codespace.CommitSHA, + EnvironmentTag: codespace.EnvironmentTag, + RuntimeSettings: runtimeSettingsMessage(effectiveRuntimeSettings(codespace)), + Username: codespaceOwner.Name, + GitUserEmail: codespaceOwner.GetEmail(), + DevContainer: devContainer, + }, nil +} + +func pullIndexFromCanonicalRef(refName string) (int64, error) { + value, ok := strings.CutPrefix(strings.TrimSpace(refName), "refs/pull/") + if !ok { + return 0, errors.New("invalid persisted pull request ref") + } + value, ok = strings.CutSuffix(value, "/head") + if !ok { + return 0, errors.New("invalid persisted pull request ref") + } + index, err := strconv.ParseInt(value, 10, 64) + if err != nil || index <= 0 { + return 0, errors.New("invalid persisted pull request ref") + } + return index, nil +} + +func canonicalStartRef(refType, refName string) (string, error) { + refName = strings.TrimSpace(refName) + switch refType { + case "branch": + if refName != "" { + return "refs/heads/" + refName, nil + } + case "tag": + if refName != "" { + return "refs/tags/" + refName, nil + } + case "pull": + if strings.HasPrefix(refName, "refs/pull/") && strings.HasSuffix(refName, "/head") { + return refName, nil + } + case "commit": + return "", nil + } + return "", fmt.Errorf("invalid persisted codespace ref %q %q", refType, refName) +} + +func gitProtocolMessage(protocol string) (codespacev1.GitProtocol, error) { + switch protocol { + case codespace_model.GitProtocolHTTP: + return codespacev1.GitProtocol_GIT_PROTOCOL_HTTP, nil + case codespace_model.GitProtocolSSH: + return codespacev1.GitProtocol_GIT_PROTOCOL_SSH, nil + default: + return codespacev1.GitProtocol_GIT_PROTOCOL_UNSPECIFIED, fmt.Errorf("unsupported git protocol %q", protocol) + } +} + +func effectiveRuntimeSettings(codespace *codespace_model.Codespace) RuntimeSettings { + settings := RuntimeSettings{ + AutoStopEnabled: setting.Codespace.Enabled, + IdleTimeoutSeconds: int64(setting.Codespace.AutoStopDefaultTimeout / time.Second), + InteractionGeneration: codespace.InteractionGeneration, + } + if !settings.AutoStopEnabled { + settings.IdleTimeoutSeconds = 0 + return settings + } + switch codespace.AutoStopMode { + case codespace_model.AutoStopModeNever: + settings.AutoStopEnabled = false + settings.IdleTimeoutSeconds = 0 + case codespace_model.AutoStopModeCustom: + settings.IdleTimeoutSeconds = codespace.AutoStopTimeoutSeconds + } + if settings.AutoStopEnabled && settings.IdleTimeoutSeconds <= 0 { + settings.IdleTimeoutSeconds = int64(setting.Codespace.AutoStopDefaultTimeout / time.Second) + } + return settings +} + +func runtimeSettingsMessage(settings RuntimeSettings) *codespacev1.EffectiveCodespaceRuntimeSettings { + return &codespacev1.EffectiveCodespaceRuntimeSettings{ + AutoStopEnabled: settings.AutoStopEnabled, + IdleTimeoutSeconds: settings.IdleTimeoutSeconds, + InteractionGeneration: settings.InteractionGeneration, + } +} + +func fetchManagerLockKey(managerID int64) string { + return fmt.Sprintf("codespace_fetch_manager_%d", managerID) +} diff --git a/services/codespace/fetch_test.go b/services/codespace/fetch_test.go new file mode 100644 index 0000000000000..7c7fe14a1f4eb --- /dev/null +++ b/services/codespace/fetch_test.go @@ -0,0 +1,615 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + git_model "gitea.dev/models/git" + "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFetchOperationsClaimsCreate(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureFetchGitTransport(t, codespace_model.GitProtocolHTTP, false, false, nil) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "10101010-1010-4010-8010-101010101010" + insertServiceCodespace(t, 0, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 31, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + }) + + result, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ + StartupCapacityAvailable: 1, + AcceptedOperationTypes: []codespacev1.AcceptedOperationType{codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_CREATE}, + AcceptedCreateTags: []string{"default"}, + }) + require.NoError(t, err) + require.Len(t, result.Operations, 1) + operation := result.Operations[0] + assert.Equal(t, codespaceUUID, operation.GetCodespaceUuid()) + assert.EqualValues(t, 31, operation.GetOperationRversion()) + assert.EqualValues(t, setting.Codespace.OperationLeaseTimeout/time.Millisecond, operation.GetLeaseValidForMilliseconds()) + create := operation.GetCreate() + require.NotNil(t, create) + assert.NotEmpty(t, create.GetRepoFullName()) + assert.NotEmpty(t, create.GetRepoCloneHttpUrl()) + assert.Empty(t, create.GetRepoCloneSshUrl()) + assert.Equal(t, "refs/heads/main", create.GetStartRef()) + assert.Equal(t, codespacev1.GitProtocol_GIT_PROTOCOL_HTTP, create.GetGitProtocol()) + assert.True(t, create.GetRuntimeSettings().GetAutoStopEnabled()) + assert.EqualValues(t, setting.Codespace.AutoStopDefaultTimeout/time.Second, create.GetRuntimeSettings().GetIdleTimeoutSeconds()) + row := loadServiceCodespace(t, codespaceUUID) + assert.Equal(t, manager.ID, row.ManagerID) + assert.Equal(t, codespace_model.OperationStatusRunning, row.OperationStatus) + assert.Positive(t, row.OperationStartedUnix) + assert.Positive(t, row.OperationDeadlineUnix) +} + +func TestFetchOperationsClaimsOnlyAcceptedCreateTag(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureFetchGitTransport(t, codespace_model.GitProtocolHTTP, false, false, nil) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"},{"tag":"gpu"}]`) + for i, tag := range []string{"default", "gpu"} { + insertServiceCodespace(t, 0, &codespace_model.Codespace{ + UUID: []string{"11111111-1010-4010-8010-101010101010", "22222222-1010-4010-8010-101010101010"}[i], + EnvironmentTag: tag, + Status: codespace_model.StatusCreating, + OperationRVersion: 1, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + }) + } + + result, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ + StartupCapacityAvailable: 1, + AcceptedOperationTypes: []codespacev1.AcceptedOperationType{codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_CREATE}, + AcceptedCreateTags: []string{"gpu"}, + }) + require.NoError(t, err) + require.Len(t, result.Operations, 1) + assert.Equal(t, "gpu", result.Operations[0].GetCreate().GetEnvironmentTag()) +} + +func TestFetchOperationsResumeUsesExistingManagerBinding(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"gpu"}]`) + codespaceUUID := "33333333-1010-4010-8010-101010101010" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + EnvironmentTag: "default", + Status: codespace_model.StatusStopped, + OperationRVersion: 2, + OperationType: codespace_model.OperationResume, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + }) + + result, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ + StartupCapacityAvailable: 1, + AcceptedOperationTypes: []codespacev1.AcceptedOperationType{codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_RESUME}, + }) + require.NoError(t, err) + require.Len(t, result.Operations, 1) + assert.Equal(t, codespaceUUID, result.Operations[0].GetCodespaceUuid()) + assert.NotNil(t, result.Operations[0].GetResume()) +} + +func TestCanonicalStartRef(t *testing.T) { + testCases := []struct { + refType string + refName string + want string + }{ + {"branch", "feature/editor", "refs/heads/feature/editor"}, + {"tag", "v1.0.0", "refs/tags/v1.0.0"}, + {"pull", "refs/pull/42/head", "refs/pull/42/head"}, + {"commit", "0123456789abcdef0123456789abcdef01234567", ""}, + } + for _, testCase := range testCases { + got, err := canonicalStartRef(testCase.refType, testCase.refName) + require.NoError(t, err) + assert.Equal(t, testCase.want, got) + } +} + +func TestBuildCreatePayloadUsesPullRequestHeadBranch(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureFetchGitTransport(t, codespace_model.GitProtocolHTTP, false, false, nil) + + payload, err := buildCreatePayload(t.Context(), &codespace_model.Codespace{ + UserID: 2, + RepoID: 1, + RefType: "pull", + RefName: "refs/pull/3/head", + CommitSHA: "985f0301dba5e7b34be866819cd15ad3d8f508ee", + DevContainerDefaultImage: setting.Codespace.DevContainerDefaultImage, + AutoStopMode: codespace_model.AutoStopModeDefault, + }) + require.NoError(t, err) + assert.Equal(t, "user2/repo1", payload.GetRepoFullName()) + assert.Contains(t, payload.GetRepoCloneHttpUrl(), "/user2/repo1.git") + assert.Equal(t, "refs/heads/branch2", payload.GetStartRef()) +} + +func TestBuildCreatePayloadUsesForkPullRequestRepository(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + require.NoError(t, db.Insert(t.Context(), &git_model.Branch{RepoID: 11, Name: "branch2"})) + configureFetchGitTransport(t, codespace_model.GitProtocolHTTP, false, false, nil) + + payload, err := buildCreatePayload(t.Context(), &codespace_model.Codespace{ + UserID: 11, + RepoID: 10, + RefType: "pull", + RefName: "refs/pull/1/head", + CommitSHA: "0abcb056019adb83", + DevContainerDefaultImage: setting.Codespace.DevContainerDefaultImage, + AutoStopMode: codespace_model.AutoStopModeDefault, + }) + require.NoError(t, err) + assert.Equal(t, "user12/repo10", payload.GetRepoFullName()) + assert.Contains(t, payload.GetRepoCloneHttpUrl(), "/user13/repo11.git") + assert.Equal(t, "refs/heads/branch2", payload.GetStartRef()) +} + +func TestFetchOperationsReturnsSSHCloneURLWhenEnabled(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureFetchGitTransport(t, codespace_model.GitProtocolSSH, false, false, []string{ + "localhost ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf", + }) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "12121212-1212-4212-8212-121212121212" + insertServiceCodespace(t, 0, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 32, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + }) + + result, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ + StartupCapacityAvailable: 1, + AcceptedOperationTypes: []codespacev1.AcceptedOperationType{codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_CREATE}, + AcceptedCreateTags: []string{"default"}, + }) + require.NoError(t, err) + require.Len(t, result.Operations, 1) + create := result.Operations[0].GetCreate() + require.NotNil(t, create) + assert.NotEmpty(t, create.GetRepoCloneHttpUrl()) + assert.NotEmpty(t, create.GetRepoCloneSshUrl()) + assert.Equal(t, codespacev1.GitProtocol_GIT_PROTOCOL_SSH, create.GetGitProtocol()) +} + +func configureFetchGitTransport(t *testing.T, protocol string, disableHTTPGit, disableSSH bool, knownHosts []string) { + t.Helper() + t.Cleanup(test.MockVariableValue(&setting.Repository.DisableHTTPGit, disableHTTPGit)) + t.Cleanup(test.MockVariableValue(&setting.SSH.Disabled, disableSSH)) + t.Cleanup(test.MockVariableValue(&setting.SSH.Domain, "localhost")) + t.Cleanup(test.MockVariableValue(&setting.SSH.Port, 22)) + t.Cleanup(test.MockVariableValue(&setting.SSH.StartBuiltinServer, false)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GitSSHKnownHosts, knownHosts)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GitProtocol, protocol)) +} + +func TestFetchOperationsSkipsCreateOutsideManagerUserScope(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + manager.UserID = 2 + _, err := db.GetEngine(t.Context()).ID(manager.ID).Cols("user_id").Update(manager) + require.NoError(t, err) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "15151515-1515-4515-8515-151515151515" + insertServiceCodespace(t, 0, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 35, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + }) + + result, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ + StartupCapacityAvailable: 1, + AcceptedOperationTypes: []codespacev1.AcceptedOperationType{codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_CREATE}, + AcceptedCreateTags: []string{"default"}, + }) + require.NoError(t, err) + assert.Empty(t, result.Operations) + row := loadServiceCodespace(t, codespaceUUID) + assert.Zero(t, row.ManagerID) + assert.Equal(t, codespace_model.OperationStatusQueued, row.OperationStatus) +} + +func TestFetchOperationsDisabledDrainsWithoutClaimingStartup(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + t.Cleanup(test.MockVariableValue(&setting.Codespace.Enabled, false)) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + runningCreateUUID := "17171717-1717-4717-8717-171717171711" + runningStopUUID := "17171717-1717-4717-8717-171717171712" + queuedCreateUUID := "17171717-1717-4717-8717-171717171713" + queuedStopUUID := "17171717-1717-4717-8717-171717171714" + now := time.Now() + originalCreateDeadline := now.Add(time.Minute).Unix() + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: runningCreateUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 51, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: now.Add(-time.Minute).Unix(), + OperationStartedUnix: now.Add(-time.Minute).Unix(), + OperationDeadlineUnix: originalCreateDeadline, + }) + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: runningStopUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 52, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: now.Add(-time.Minute).Unix(), + OperationStartedUnix: now.Add(-time.Minute).Unix(), + OperationDeadlineUnix: now.Add(time.Minute).Unix(), + }) + insertServiceCodespace(t, 0, &codespace_model.Codespace{ + UUID: queuedCreateUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 53, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: now.Unix(), + }) + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: queuedStopUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 54, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: now.Unix(), + }) + + result, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ + StartupCapacityAvailable: 1, + AcceptedOperationTypes: []codespacev1.AcceptedOperationType{ + codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_CREATE, + codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_RESUME, + }, + AcceptedCreateTags: []string{"default"}, + CleanupCapacityAvailable: 1, + ObservedOperations: []*codespacev1.ObservedOperation{ + {CodespaceUuid: runningCreateUUID, OperationRversion: 51}, + {CodespaceUuid: runningStopUUID, OperationRversion: 52}, + }, + }) + require.NoError(t, err) + require.Len(t, result.Operations, 2) + assert.NotNil(t, result.Operations[0].GetAbortCreate()) + assert.Zero(t, result.Operations[0].GetLeaseValidForMilliseconds()) + assert.NotNil(t, result.Operations[1].GetStop()) + require.Len(t, result.RenewedLeases, 1) + assert.Equal(t, runningStopUUID, result.RenewedLeases[0].GetCodespaceUuid()) + + assert.Equal(t, originalCreateDeadline, loadServiceCodespace(t, runningCreateUUID).OperationDeadlineUnix) + assert.Zero(t, loadServiceCodespace(t, queuedCreateUUID).ManagerID) + assert.Equal(t, codespace_model.OperationStatusQueued, loadServiceCodespace(t, queuedCreateUUID).OperationStatus) + assert.Equal(t, codespace_model.OperationStatusRunning, loadServiceCodespace(t, queuedStopUUID).OperationStatus) +} + +func TestApplyQueuedTimeoutUsesQueuedStateMapping(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + now := time.Now().Unix() + cases := []struct { + name string + uuid string + status string + operationType string + withCredential bool + expectedStatus string + expectToken bool + expectKey bool + }{ + { + name: "create", + uuid: "16161616-1616-4616-8616-161616161611", + status: codespace_model.StatusCreating, + operationType: codespace_model.OperationCreate, + withCredential: true, + expectedStatus: codespace_model.StatusFailed, + }, + { + name: "resume", + uuid: "16161616-1616-4616-8616-161616161612", + status: codespace_model.StatusStopped, + operationType: codespace_model.OperationResume, + withCredential: true, + expectedStatus: codespace_model.StatusStopped, + expectKey: true, + }, + { + name: "stop", + uuid: "16161616-1616-4616-8616-161616161613", + status: codespace_model.StatusRunning, + operationType: codespace_model.OperationStop, + withCredential: true, + expectedStatus: codespace_model.StatusRunning, + expectToken: true, + expectKey: true, + }, + { + name: "delete", + uuid: "16161616-1616-4616-8616-161616161614", + status: codespace_model.StatusDeleting, + operationType: codespace_model.OperationDelete, + expectedStatus: codespace_model.StatusFailed, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: tc.uuid, + Status: tc.status, + OperationRVersion: 40, + OperationType: tc.operationType, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: now - int64(setting.Codespace.QueueTimeout/time.Second) - 1, + }) + if tc.withCredential { + insertServiceCredentials(t, tc.uuid) + } + + require.NoError(t, applyQueuedTimeout(t.Context(), loadServiceCodespace(t, tc.uuid), now)) + + row := loadServiceCodespace(t, tc.uuid) + assert.Equal(t, tc.expectedStatus, row.Status) + assert.Empty(t, row.OperationType) + assert.Empty(t, row.OperationStatus) + assert.Empty(t, row.OperationTrigger) + assert.Greater(t, row.UpdatedUnix, int64(1)) + if tc.expectToken { + assertServiceExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", tc.uuid) + } else { + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", tc.uuid) + } + if tc.expectKey { + assertServiceExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", tc.uuid) + } else { + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", tc.uuid) + } + }) + } +} + +func TestFetchOperationsRenewsObservedOperation(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "20202020-2020-4020-8020-202020202020" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 32, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Add(-time.Minute).Unix(), + OperationStartedUnix: time.Now().Add(-time.Minute).Unix(), + OperationDeadlineUnix: time.Now().Add(time.Second).Unix(), + }) + + result, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ + ObservedOperations: []*codespacev1.ObservedOperation{{ + CodespaceUuid: codespaceUUID, + OperationRversion: 32, + }}, + }) + require.NoError(t, err) + assert.Empty(t, result.Operations) + require.Len(t, result.RenewedLeases, 1) + assert.Equal(t, codespaceUUID, result.RenewedLeases[0].GetCodespaceUuid()) + assert.EqualValues(t, 32, result.RenewedLeases[0].GetOperationRversion()) + assert.EqualValues(t, setting.Codespace.OperationLeaseTimeout/time.Millisecond, result.RenewedLeases[0].GetLeaseValidForMilliseconds()) + assert.Greater(t, loadServiceCodespace(t, codespaceUUID).OperationDeadlineUnix, time.Now().Unix()+1) +} + +func TestFetchOperationsRejectsStateHistoryConflictBeforeWrites(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + renewedUUID := "21212121-2121-4121-8121-212121212121" + originalDeadline := time.Now().Add(time.Minute).Unix() + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: renewedUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 36, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Add(-time.Minute).Unix(), + OperationStartedUnix: time.Now().Add(-time.Minute).Unix(), + OperationDeadlineUnix: originalDeadline, + }) + conflictUUID := "22222222-2222-4222-8222-222222222221" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: conflictUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 37, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + OperationStartedUnix: time.Now().Unix(), + OperationDeadlineUnix: time.Now().Add(time.Minute).Unix(), + }) + + _, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ + ObservedOperations: []*codespacev1.ObservedOperation{ + {CodespaceUuid: renewedUUID, OperationRversion: 36}, + {CodespaceUuid: conflictUUID, OperationRversion: 38}, + }, + }) + require.ErrorIs(t, err, ErrFetchStateHistoryConflict) + assert.Equal(t, originalDeadline, loadServiceCodespace(t, renewedUUID).OperationDeadlineUnix) +} + +func TestFetchOperationsWaitsForUnobservedRunningOperation(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "23232323-2323-4323-8323-232323232321" + originalDeadline := time.Now().Add(time.Minute).Unix() + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 38, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Add(-time.Minute).Unix(), + OperationStartedUnix: time.Now().Add(-time.Minute).Unix(), + OperationDeadlineUnix: originalDeadline, + }) + + result, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{}) + require.NoError(t, err) + assert.Empty(t, result.Operations) + assert.Empty(t, result.RenewedLeases) + assert.Equal(t, originalDeadline, loadServiceCodespace(t, codespaceUUID).OperationDeadlineUnix) +} + +func TestFetchOperationsReturnsCurrentPayloadForLowerObservedVersion(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "24242424-2424-4424-8424-242424242421" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 39, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Add(-time.Minute).Unix(), + OperationStartedUnix: time.Now().Add(-time.Minute).Unix(), + OperationDeadlineUnix: time.Now().Add(time.Minute).Unix(), + }) + + result, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ + ObservedOperations: []*codespacev1.ObservedOperation{{ + CodespaceUuid: codespaceUUID, + OperationRversion: 38, + }}, + }) + require.NoError(t, err) + require.Len(t, result.Operations, 1) + assert.Empty(t, result.RenewedLeases) + assert.Equal(t, codespaceUUID, result.Operations[0].GetCodespaceUuid()) + assert.EqualValues(t, 39, result.Operations[0].GetOperationRversion()) + assert.NotNil(t, result.Operations[0].GetStop()) + assert.Greater(t, loadServiceCodespace(t, codespaceUUID).OperationDeadlineUnix, time.Now().Unix()+1) +} + +func TestFetchOperationsClaimsCleanupStop(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "30303030-3030-4030-8030-303030303030" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 33, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + }) + + result, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ + CleanupCapacityAvailable: 1, + }) + require.NoError(t, err) + require.Len(t, result.Operations, 1) + assert.NotNil(t, result.Operations[0].GetStop()) + assert.Equal(t, codespaceUUID, result.Operations[0].GetCodespaceUuid()) + assert.Equal(t, codespace_model.OperationStatusRunning, loadServiceCodespace(t, codespaceUUID).OperationStatus) +} + +func TestFetchOperationsRejectsStateHistoryConflict(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "40404040-4040-4040-8040-404040404040" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 34, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + OperationStartedUnix: time.Now().Unix(), + OperationDeadlineUnix: time.Now().Add(time.Minute).Unix(), + }) + + _, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ + ObservedOperations: []*codespacev1.ObservedOperation{{ + CodespaceUuid: codespaceUUID, + OperationRversion: 35, + }}, + }) + require.ErrorIs(t, err, ErrFetchStateHistoryConflict) +} + +func markServiceManagerOnline(t *testing.T, manager *codespace_model.Manager, tagsJSON string) { + t.Helper() + manager.RuntimeState = codespace_model.ManagerRuntimeStateOnline + manager.LastOnlineUnix = time.Now().Unix() + manager.TagsJSON = tagsJSON + _, err := db.GetEngine(t.Context()).ID(manager.ID).Cols("runtime_state", "last_online_unix", "tags_json").Update(manager) + require.NoError(t, err) +} diff --git a/services/codespace/git_ssh_key.go b/services/codespace/git_ssh_key.go new file mode 100644 index 0000000000000..b2fc7693fe2d1 --- /dev/null +++ b/services/codespace/git_ssh_key.go @@ -0,0 +1,473 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "crypto/rsa" + "errors" + "fmt" + "os" + "slices" + "strings" + "time" + + asymkey_model "gitea.dev/models/asymkey" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/perm" + "gitea.dev/models/unit" + user_model "gitea.dev/models/user" + "gitea.dev/modules/globallock" + "gitea.dev/modules/setting" + asymkey_service "gitea.dev/services/asymkey" + + "golang.org/x/crypto/ssh" + "xorm.io/builder" +) + +var ( + // ErrRuntimeGitSSHKeyLoginRestricted is returned when the Codespace creator cannot currently log in. + ErrRuntimeGitSSHKeyLoginRestricted = errors.New("codespace user login restricted") + // ErrRuntimeGitSSHKeyInvalidPublicKey is returned when the Runtime submitted an invalid key. + ErrRuntimeGitSSHKeyInvalidPublicKey = errors.New("invalid codespace git ssh public key") + // ErrRuntimeGitSSHKeyConflict is returned when an existing key binding cannot be reused. + ErrRuntimeGitSSHKeyConflict = errors.New("codespace git ssh key conflict") + // ErrRuntimeGitSSHKeyIntegrity is returned when persisted key rows are internally inconsistent. + ErrRuntimeGitSSHKeyIntegrity = errors.New("codespace git ssh key data integrity error") + // ErrResolveGitSSHKeyBindingNotFound is returned when a Codespace key row has no Codespace binding. + ErrResolveGitSSHKeyBindingNotFound = errors.New("codespace git ssh key binding not found") + // ErrResolveGitSSHKeyBindingInvalid is returned when a Codespace key binding is internally inconsistent. + ErrResolveGitSSHKeyBindingInvalid = errors.New("codespace git ssh key binding invalid") + // ErrResolveGitSSHKeyRepoMismatch is returned when the Codespace key is used for another repository. + ErrResolveGitSSHKeyRepoMismatch = errors.New("codespace git ssh key repository mismatch") + // ErrResolveGitSSHKeyStateUnavailable is returned when the Codespace lifecycle cannot use Git SSH. + ErrResolveGitSSHKeyStateUnavailable = errors.New("codespace git ssh key state unavailable") + // ErrResolveGitSSHKeyUserNotFound is returned when the Codespace creator row is missing. + ErrResolveGitSSHKeyUserNotFound = errors.New("codespace git ssh key user not found") + // ErrResolveGitSSHKeyLoginRestricted is returned when the Codespace creator cannot currently log in. + ErrResolveGitSSHKeyLoginRestricted = errors.New("codespace git ssh key user login restricted") +) + +type normalizedGitSSHKey struct { + Content string + Fingerprint string +} + +type runtimeGitSSHKeyOptions struct { + CodespaceUUID string + OperationRVersion int64 + PublicKey []byte +} + +func ensureRuntimeGitSSHKey(ctx context.Context, manager *codespace_model.Manager, opts runtimeGitSSHKeyOptions) ([]string, error) { + if !setting.Codespace.Enabled { + return nil, ErrRequestRuntimeAccessStateUnavailable + } + if manager == nil || manager.ID <= 0 { + return nil, errors.New("manager is required") + } + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return nil, err + } + if opts.OperationRVersion <= 0 { + return nil, errors.New("operation_rversion must be positive") + } + allowed, err := currentManagerAllowsOnlineOrRecovering(ctx, manager.ID) + if err != nil { + return nil, err + } + if !allowed { + return nil, ErrRequestRuntimeAccessManagerOffline + } + key, err := normalizeGitSSHPublicKey(opts.PublicKey) + if err != nil { + return nil, err + } + knownHostsLines, err := availableGitSSHKnownHostsLines() + if err != nil { + return nil, err + } + + err = globallock.LockAndDo(ctx, runtimeGitSSHKeyLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + allowed, err = currentManagerAllowsOnlineOrRecovering(ctx, manager.ID) + if err != nil { + return err + } + if !allowed { + return ErrRequestRuntimeAccessManagerOffline + } + codespace, err := loadRuntimeAccessCodespace(ctx, manager.ID, opts.CodespaceUUID, opts.OperationRVersion) + if err != nil { + return err + } + user, err := user_model.GetUserByID(ctx, codespace.UserID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + return ErrRequestRuntimeAccessUserNotFound + } + return err + } + canUseCodespace, err := codespaceUserCanLogIn(ctx, user) + if err != nil { + return err + } + if !canUseCodespace { + return ErrRuntimeGitSSHKeyLoginRestricted + } + return globallock.LockAndDo(ctx, asymkey_model.PublicKeyFingerprintLockKey(key.Fingerprint), func(ctx context.Context) error { + return ensureGitSSHKeyBinding(ctx, codespace, key) + }) + }) + if err != nil { + return nil, err + } + if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { + return nil, fmt.Errorf("%w: rewrite authorized keys: %v", ErrRuntimeGitSSHKeyIntegrity, err) + } + return knownHostsLines, nil +} + +// ResolveGitSSHKeyUser returns the Codespace creator allowed to use one Git SSH key for a repository. +func ResolveGitSSHKeyUser(ctx context.Context, key *asymkey_model.PublicKey, repoID int64, unitType unit.Type, mode perm.AccessMode) (*user_model.User, error) { + if key == nil || key.ID <= 0 || key.Type != asymkey_model.KeyTypeCodespace { + return nil, ErrResolveGitSSHKeyBindingInvalid + } + relation := new(codespace_model.SSHKey) + has, err := db.GetEngine(ctx).Where("key_id = ?", key.ID).Get(relation) + if err != nil { + return nil, err + } + if !has { + return nil, ErrResolveGitSSHKeyBindingNotFound + } + + codespace := new(codespace_model.Codespace) + has, err = db.GetEngine(ctx).ID(relation.CodespaceUUID).Get(codespace) + if err != nil { + return nil, err + } + if !has || codespace.UserID != key.OwnerID { + return nil, ErrResolveGitSSHKeyBindingInvalid + } + if repoID <= 0 || codespace.RepoID <= 0 { + return nil, ErrResolveGitSSHKeyRepoMismatch + } + if codespace.RepoID != repoID { + authorization := new(codespace_model.PermissionAuthorization) + has, err := db.GetEngine(ctx).ID(codespace.PermissionAuthorizationID).Where("user_id = ? AND source_repo_id = ? AND revoked_unix = 0", codespace.UserID, codespace.RepoID).Get(authorization) + if err != nil { + return nil, err + } + if !has { + return nil, ErrResolveGitSSHKeyRepoMismatch + } + rule := new(codespace_model.PermissionRepository) + has, err = db.GetEngine(ctx). + Where("authorization_id = ? AND target_repo_id = ? AND unit_type = ? AND granted_mode >= ?", authorization.ID, repoID, unitType, mode). + Get(rule) + if err != nil { + return nil, err + } + if !has { + return nil, ErrResolveGitSSHKeyRepoMismatch + } + } + if !codespaceGitSSHCommandAllowed(codespace, time.Now().Unix()) { + return nil, ErrResolveGitSSHKeyStateUnavailable + } + + user, err := user_model.GetUserByID(ctx, codespace.UserID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + return nil, ErrResolveGitSSHKeyUserNotFound + } + return nil, err + } + if !user.IsActive || user.ProhibitLogin { + return nil, ErrResolveGitSSHKeyLoginRestricted + } + return user, nil +} + +func codespaceGitSSHCommandAllowed(codespace *codespace_model.Codespace, now int64) bool { + switch codespace.Status { + case codespace_model.StatusCreating: + return codespace.OperationType == codespace_model.OperationCreate && + codespace.OperationRVersion > 0 && + codespace.OperationDeadlineUnix > now + case codespace_model.StatusRunning: + return true + case codespace_model.StatusStopped: + return codespace.OperationType == codespace_model.OperationResume && + codespace.OperationRVersion > 0 && + codespace.OperationDeadlineUnix > now + default: + return false + } +} + +func ensureGitSSHKeyBinding(ctx context.Context, codespace *codespace_model.Codespace, key normalizedGitSSHKey) error { + return db.WithTx(ctx, func(ctx context.Context) error { + relation := new(codespace_model.SSHKey) + hasRelation, err := db.GetEngine(ctx).ID(codespace.UUID).Get(relation) + if err != nil { + return err + } + if hasRelation { + return ensureExistingGitSSHKey(ctx, relation, key) + } + + keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + Fingerprint: key.Fingerprint, + }) + if err != nil { + return err + } + if len(keys) > 1 { + return ErrRuntimeGitSSHKeyIntegrity + } + if len(keys) == 1 { + return ensureOrphanedGitSSHKeyBinding(ctx, codespace, keys[0], key) + } + + publicKey := newCodespaceGitPublicKey(codespace, key) + if _, err := db.GetEngine(ctx).Insert(publicKey); err != nil { + return err + } + return insertCodespaceGitSSHKeyBinding(ctx, codespace.UUID, publicKey.ID) + }) +} + +func newCodespaceGitPublicKey(codespace *codespace_model.Codespace, key normalizedGitSSHKey) *asymkey_model.PublicKey { + return &asymkey_model.PublicKey{ + OwnerID: codespace.UserID, + Name: codespaceGitSSHKeyName(codespace.UUID), + Fingerprint: key.Fingerprint, + Content: key.Content, + Mode: perm.AccessModeWrite, + Type: asymkey_model.KeyTypeCodespace, + Verified: false, + } +} + +func ensureOrphanedGitSSHKeyBinding(ctx context.Context, codespace *codespace_model.Codespace, publicKey *asymkey_model.PublicKey, key normalizedGitSSHKey) error { + expectedKey := newCodespaceGitPublicKey(codespace, key) + if publicKey.Type != expectedKey.Type || + publicKey.OwnerID != expectedKey.OwnerID || + publicKey.Name != expectedKey.Name || + publicKey.Content != expectedKey.Content || + publicKey.Fingerprint != expectedKey.Fingerprint { + return ErrRuntimeGitSSHKeyConflict + } + + relation := new(codespace_model.SSHKey) + has, err := db.GetEngine(ctx).Where("key_id = ?", publicKey.ID).Get(relation) + if err != nil { + return err + } + if has { + return ErrRuntimeGitSSHKeyConflict + } + + return insertCodespaceGitSSHKeyBinding(ctx, codespace.UUID, publicKey.ID) +} + +func insertCodespaceGitSSHKeyBinding(ctx context.Context, codespaceUUID string, keyID int64) error { + _, err := db.GetEngine(ctx).Insert(&codespace_model.SSHKey{ + CodespaceUUID: codespaceUUID, + KeyID: keyID, + }) + return err +} + +func codespaceGitSSHKeyName(codespaceUUID string) string { + return "codespace-" + codespaceUUID +} + +func ensureExistingGitSSHKey(ctx context.Context, relation *codespace_model.SSHKey, key normalizedGitSSHKey) error { + publicKey := new(asymkey_model.PublicKey) + has, err := db.GetEngine(ctx).ID(relation.KeyID).Get(publicKey) + if err != nil { + return err + } + if !has || publicKey.Type != asymkey_model.KeyTypeCodespace { + return ErrRuntimeGitSSHKeyIntegrity + } + if publicKey.Content != key.Content || publicKey.Fingerprint != key.Fingerprint { + return ErrRuntimeGitSSHKeyConflict + } + return nil +} + +func normalizeGitSSHPublicKey(raw []byte) (normalizedGitSSHKey, error) { + publicKey, err := ssh.ParsePublicKey(raw) + if err != nil { + return normalizedGitSSHKey{}, fmt.Errorf("%w: %v", ErrRuntimeGitSSHKeyInvalidPublicKey, err) + } + if err := validateGitSSHPublicKeyType(publicKey); err != nil { + return normalizedGitSSHKey{}, err + } + content := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(publicKey))) + return normalizedGitSSHKey{ + Content: content, + Fingerprint: ssh.FingerprintSHA256(publicKey), + }, nil +} + +func validateGitSSHPublicKeyType(publicKey ssh.PublicKey) error { + switch publicKey.Type() { + case ssh.KeyAlgoED25519: + return nil + case ssh.KeyAlgoRSA: + cryptoKey, ok := publicKey.(ssh.CryptoPublicKey) + if !ok { + return fmt.Errorf("%w: ssh-rsa key cannot be inspected", ErrRuntimeGitSSHKeyInvalidPublicKey) + } + rsaKey, ok := cryptoKey.CryptoPublicKey().(*rsa.PublicKey) + if !ok || rsaKey.N == nil || rsaKey.N.BitLen() != 4096 { + return fmt.Errorf("%w: ssh-rsa key must be 4096 bits", ErrRuntimeGitSSHKeyInvalidPublicKey) + } + return nil + default: + return fmt.Errorf("%w: key type must be ssh-ed25519 or rsa-4096", ErrRuntimeGitSSHKeyInvalidPublicKey) + } +} + +func gitSSHKnownHostsLines() ([]string, error) { + if len(setting.Codespace.GitSSHKnownHosts) > 0 { + return configuredGitSSHKnownHostsLines(setting.Codespace.GitSSHKnownHosts) + } + if !setting.SSH.StartBuiltinServer { + return nil, errors.New("codespace git ssh known hosts are required when builtin ssh server is disabled") + } + return builtinGitSSHKnownHostsLines() +} + +func gitSSHCloneKnownHostsLines() ([]string, error) { + if setting.SSH.Disabled { + return nil, fmt.Errorf("%w: [server] DISABLE_SSH=true", ErrRequestRuntimeAccessStateUnavailable) + } + lines, err := gitSSHKnownHostsLines() + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrRequestRuntimeAccessStateUnavailable, err.Error()) + } + return lines, nil +} + +func availableGitSSHKnownHostsLines() ([]string, error) { + protocol, err := createGitProtocol() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrRequestRuntimeAccessStateUnavailable, err) + } + capabilities, err := resolveGitTransportCapabilities(protocol) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrRequestRuntimeAccessStateUnavailable, err) + } + if !capabilities.SSHEnabled { + return nil, nil + } + return gitSSHCloneKnownHostsLines() +} + +func gitSSHCloneDisabledReason() string { + if setting.SSH.Disabled { + return "[server] DISABLE_SSH=true" + } + if _, err := gitSSHKnownHostsLines(); err != nil { + return err.Error() + } + return "" +} + +func configuredGitSSHKnownHostsLines(configured []string) ([]string, error) { + hostPattern, err := gitSSHHostPattern() + if err != nil { + return nil, err + } + lines := make([]string, 0, len(configured)) + for _, line := range configured { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 3 { + return nil, errors.New("invalid codespace git ssh known_hosts line") + } + if fields[0] != hostPattern { + return nil, fmt.Errorf("codespace git ssh known_hosts host %q does not match %q", fields[0], hostPattern) + } + if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.Join(fields[1:], " "))); err != nil { + return nil, fmt.Errorf("invalid codespace git ssh known_hosts key for %q: %w", fields[0], err) + } + lines = append(lines, strings.Join(fields, " ")) + } + if len(lines) == 0 { + return nil, errors.New("codespace git ssh known hosts are required") + } + slices.Sort(lines) + return lines, nil +} + +func builtinGitSSHKnownHostsLines() ([]string, error) { + hostPattern, err := gitSSHHostPattern() + if err != nil { + return nil, err + } + + lines := make([]string, 0, len(setting.SSH.ServerHostKeys)) + for _, keyPath := range setting.SSH.ServerHostKeys { + keyPath = strings.TrimSpace(keyPath) + if keyPath == "" { + continue + } + data, err := os.ReadFile(keyPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return nil, fmt.Errorf("read ssh host key %q: %w", keyPath, err) + } + signer, err := ssh.ParsePrivateKey(data) + if err != nil { + return nil, fmt.Errorf("parse ssh host key %q: %w", keyPath, err) + } + lines = append(lines, hostPattern+" "+strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey())))) + } + if len(lines) == 0 { + return nil, errors.New("ssh host keys are required") + } + slices.Sort(lines) + return lines, nil +} + +func gitSSHHostPattern() (string, error) { + host := strings.TrimSpace(setting.SSH.Domain) + if host == "" { + return "", errors.New("ssh domain is required") + } + hostPattern := host + if setting.SSH.Port != 22 { + hostPattern = fmt.Sprintf("[%s]:%d", host, setting.SSH.Port) + } + return hostPattern, nil +} + +func deleteGitSSHKey(ctx context.Context, codespaceUUID string) error { + relation := new(codespace_model.SSHKey) + has, err := db.GetEngine(ctx).ID(codespaceUUID).Get(relation) + if err != nil || !has { + return err + } + if _, err := db.GetEngine(ctx).ID(codespaceUUID).Delete(new(codespace_model.SSHKey)); err != nil { + return err + } + _, err = db.GetEngine(ctx).Where(builder.Eq{"id": relation.KeyID, "type": asymkey_model.KeyTypeCodespace}).Delete(new(asymkey_model.PublicKey)) + return err +} + +func runtimeGitSSHKeyLockKey(codespaceUUID string) string { + return "codespace_git_ssh_key_" + codespaceUUID +} diff --git a/services/codespace/git_ssh_key_test.go b/services/codespace/git_ssh_key_test.go new file mode 100644 index 0000000000000..5491e16621238 --- /dev/null +++ b/services/codespace/git_ssh_key_test.go @@ -0,0 +1,511 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "crypto/rand" + "crypto/rsa" + "path/filepath" + "strings" + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + asymkey_model "gitea.dev/models/asymkey" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/perm" + "gitea.dev/models/unit" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + "gitea.dev/modules/generate" + "gitea.dev/modules/setting" + ssh_module "gitea.dev/modules/ssh" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +const ( + testGitSSHPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf" + testOtherGitSSHPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHjnNEfE88W1pvBLdV3otv28x760gdmPao3lVD5uAt9" +) + +func TestRuntimeGitSSHKeyCreateReturnsStableKnownHosts(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureServiceGitSSHHostKey(t) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "12121212-1212-4212-8212-121212121212" + insertActiveCreateCodespaceForGitSSHKey(t, manager.ID, codespaceUUID, 15) + + result, err := ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 15, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + require.Len(t, result, 1) + assert.True(t, strings.HasPrefix(result[0], "[gitea.example.com]:2222 ssh-ed25519 ")) + relation := loadServiceSSHKeyRelation(t, codespaceUUID) + publicKey := loadServicePublicKey(t, relation.KeyID) + assert.EqualValues(t, asymkey_model.KeyTypeCodespace, publicKey.Type) + assert.False(t, publicKey.Verified) + assert.Equal(t, serviceCanonicalPublicKey(t, testGitSSHPublicKey), publicKey.Content) + + again, err := ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 15, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + assert.Equal(t, result, again) + assert.Equal(t, relation.KeyID, loadServiceSSHKeyRelation(t, codespaceUUID).KeyID) +} + +func TestNormalizeGitSSHPublicKeyAcceptsRSA4096(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + require.NoError(t, err) + publicKey, err := ssh.NewPublicKey(&privateKey.PublicKey) + require.NoError(t, err) + + normalized, err := normalizeGitSSHPublicKey(publicKey.Marshal()) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(normalized.Content, "ssh-rsa ")) +} + +func TestNormalizeGitSSHPublicKeyRejectsRSA2048(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + publicKey, err := ssh.NewPublicKey(&privateKey.PublicKey) + require.NoError(t, err) + + _, err = normalizeGitSSHPublicKey(publicKey.Marshal()) + require.ErrorIs(t, err, ErrRuntimeGitSSHKeyInvalidPublicKey) +} + +func TestResolveGitSSHKeyUserUsesCodespaceBinding(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureServiceGitSSHHostKey(t) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "13131313-1313-4313-8313-131313131313" + insertActiveCreateCodespaceForGitSSHKey(t, manager.ID, codespaceUUID, 20) + _, err := ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 20, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + _, err = db.GetEngine(t.Context()).ID(codespaceUUID).Cols( + "status", + "operation_type", + "operation_status", + "operation_trigger", + ).Update(&codespace_model.Codespace{Status: codespace_model.StatusRunning}) + require.NoError(t, err) + publicKey := loadServicePublicKey(t, loadServiceSSHKeyRelation(t, codespaceUUID).KeyID) + + user, err := ResolveGitSSHKeyUser(t.Context(), publicKey, 2, unit.TypeCode, perm.AccessModeRead) + require.NoError(t, err) + assert.EqualValues(t, 1, user.ID) + + _, err = ResolveGitSSHKeyUser(t.Context(), publicKey, 3, unit.TypeCode, perm.AccessModeRead) + require.ErrorIs(t, err, ErrResolveGitSSHKeyRepoMismatch) + + now := time.Now().Unix() + authorization := &codespace_model.PermissionAuthorization{ + UserID: 1, SourceRepoID: 2, RequestHash: "git-ssh-permissions", + CreatedUnix: now, UpdatedUnix: now, + } + require.NoError(t, db.Insert(t.Context(), authorization)) + rule := &codespace_model.PermissionRepository{ + AuthorizationID: authorization.ID, TargetRepoID: 3, UnitType: unit.TypeCode, + RequestedMode: perm.AccessModeWrite, GrantedMode: perm.AccessModeRead, + } + require.NoError(t, db.Insert(t.Context(), rule)) + updated, err := db.GetEngine(t.Context()).ID(codespaceUUID).Cols("permission_authorization_id").Update(&codespace_model.Codespace{PermissionAuthorizationID: authorization.ID}) + require.NoError(t, err) + require.EqualValues(t, 1, updated) + + _, err = ResolveGitSSHKeyUser(t.Context(), publicKey, 3, unit.TypeCode, perm.AccessModeRead) + require.NoError(t, err) + _, err = ResolveGitSSHKeyUser(t.Context(), publicKey, 3, unit.TypeCode, perm.AccessModeWrite) + require.ErrorIs(t, err, ErrResolveGitSSHKeyRepoMismatch) + + rule.GrantedMode = perm.AccessModeWrite + updated, err = db.GetEngine(t.Context()).ID(rule.ID).Cols("granted_mode").Update(rule) + require.NoError(t, err) + require.EqualValues(t, 1, updated) + _, err = ResolveGitSSHKeyUser(t.Context(), publicKey, 3, unit.TypeCode, perm.AccessModeWrite) + require.NoError(t, err) + + require.NoError(t, RevokePermissionAuthorization(t.Context(), 1, authorization.ID)) + _, err = ResolveGitSSHKeyUser(t.Context(), publicKey, 3, unit.TypeCode, perm.AccessModeRead) + require.ErrorIs(t, err, ErrResolveGitSSHKeyRepoMismatch) +} + +func TestRuntimeGitSSHKeyRejectsDifferentExistingKey(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureServiceGitSSHHostKey(t) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "23232323-2323-4232-8232-232323232323" + insertActiveCreateCodespaceForGitSSHKey(t, manager.ID, codespaceUUID, 16) + _, err := ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 16, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + original := loadServiceSSHKeyRelation(t, codespaceUUID) + + _, err = ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 16, + PublicKey: servicePublicKeyWire(t, testOtherGitSSHPublicKey), + }) + require.ErrorIs(t, err, ErrRuntimeGitSSHKeyConflict) + assert.Equal(t, original.KeyID, loadServiceSSHKeyRelation(t, codespaceUUID).KeyID) +} + +func TestRuntimeGitSSHKeyRepairsOrphanedSameCodespaceKey(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureServiceGitSSHHostKey(t) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "24242424-2424-4242-8242-242424242424" + insertActiveCreateCodespaceForGitSSHKey(t, manager.ID, codespaceUUID, 16) + key, err := normalizeGitSSHPublicKey(servicePublicKeyWire(t, testGitSSHPublicKey)) + require.NoError(t, err) + publicKey := &asymkey_model.PublicKey{ + OwnerID: 1, + Name: codespaceGitSSHKeyName(codespaceUUID), + Fingerprint: key.Fingerprint, + Content: key.Content, + Mode: perm.AccessModeWrite, + Type: asymkey_model.KeyTypeCodespace, + } + require.NoError(t, db.Insert(t.Context(), publicKey)) + + _, err = ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 16, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + assert.Equal(t, publicKey.ID, loadServiceSSHKeyRelation(t, codespaceUUID).KeyID) +} + +func TestRuntimeGitSSHKeyAllowsStableRunningRecovery(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureServiceGitSSHHostKey(t) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "34343434-3434-4343-8343-343434343434" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 17, + }) + + _, err := ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 17, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + assert.NotZero(t, loadServiceSSHKeyRelation(t, codespaceUUID).KeyID) +} + +func TestRuntimeGitSSHKeyUsesCurrentManagerAvailability(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureServiceGitSSHHostKey(t) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + staleManager := *manager + codespaceUUID := "45454545-4545-4454-8454-454545454546" + insertActiveCreateCodespaceForGitSSHKey(t, manager.ID, codespaceUUID, 18) + _, err := db.GetEngine(t.Context()). + ID(manager.ID). + Cols("last_online_unix"). + Update(&codespace_model.Manager{LastOnlineUnix: 1}) + require.NoError(t, err) + + _, err = ensureRuntimeGitSSHKey(t.Context(), &staleManager, runtimeGitSSHKeyOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 18, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.ErrorIs(t, err, ErrRequestRuntimeAccessManagerOffline) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", codespaceUUID) +} + +func TestRuntimeGitSSHKeyRejectsLoginRestrictedUser(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureServiceGitSSHHostKey(t) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "46464646-4646-4464-8464-464646464646" + insertActiveCreateCodespaceForGitSSHKey(t, manager.ID, codespaceUUID, 19) + _, err := db.GetEngine(t.Context()). + ID(1). + Cols("must_change_password"). + Update(&user_model.User{MustChangePassword: true}) + require.NoError(t, err) + + _, err = ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 19, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.ErrorIs(t, err, ErrRuntimeGitSSHKeyLoginRestricted) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", codespaceUUID) +} + +func TestRuntimeGitSSHKeyRejectsInvalidPublicKey(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureServiceGitSSHHostKey(t) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + _, err := ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: "45454545-4545-4454-8454-454545454545", + OperationRVersion: 1, + PublicKey: []byte("not-ssh-wire"), + }) + require.ErrorIs(t, err, ErrRuntimeGitSSHKeyInvalidPublicKey) +} + +func TestRuntimeGitSSHKeyAllowsHTTPCloneWithoutKnownHosts(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureGitTransportTestSettings(t, codespace_model.GitProtocolHTTP, false, false, nil) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "13131313-1313-4313-8313-131313131313" + insertActiveCreateCodespaceForGitSSHKey(t, manager.ID, codespaceUUID, 1) + + result, err := ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 1, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + assert.Empty(t, result) + assert.NotZero(t, loadServiceSSHKeyRelation(t, codespaceUUID).KeyID) +} + +func TestRuntimeGitSSHKeyReturnsSSHKnownHostsForHTTPPreferredFallback(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureGitTransportTestSettings(t, codespace_model.GitProtocolHTTP, false, false, []string{ + "gitea.example.com " + testGitSSHPublicKey, + }) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "14141414-1414-4414-8414-141414141414" + insertActiveCreateCodespaceForGitSSHKey(t, manager.ID, codespaceUUID, 1) + + result, err := ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 1, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + assert.Equal(t, []string{"gitea.example.com " + testGitSSHPublicKey}, result) +} + +func TestRuntimeGitSSHKeyRejectsSSHCloneWithoutKnownHosts(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureGitTransportTestSettings(t, codespace_model.GitProtocolSSH, false, false, nil) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "23232323-2323-4323-8323-232323232323" + insertActiveCreateCodespaceForGitSSHKey(t, manager.ID, codespaceUUID, 1) + + _, err := ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 1, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.ErrorIs(t, err, ErrRequestRuntimeAccessStateUnavailable) + assert.ErrorContains(t, err, "known hosts are required") +} + +func TestFinalizeFailedDeletesCodespaceGitPublicKey(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureServiceGitSSHHostKey(t) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "56565656-5656-4565-8565-565656565656" + insertActiveCreateCodespaceForGitSSHKey(t, manager.ID, codespaceUUID, 18) + _, err := ensureRuntimeGitSSHKey(t.Context(), manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 18, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + keyID := loadServiceSSHKeyRelation(t, codespaceUUID).KeyID + + outcome, err := FinalizeOperation(t.Context(), manager, FinalizeOperationOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 18, + OperationType: codespacev1.OperationType_OPERATION_TYPE_CREATE, + FinalStatus: codespacev1.FinalStatus_FINAL_STATUS_FAILED, + }) + require.NoError(t, err) + assert.False(t, outcome.GetResourceAbsent()) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", codespaceUUID) + assertServiceNotExists(t, new(asymkey_model.PublicKey), "id = ?", keyID) +} + +func TestGitSSHKnownHostsLinesUsesConfiguredKnownHosts(t *testing.T) { + t.Cleanup(test.MockVariableValue(&setting.SSH.Domain, "gitea.example.com")) + t.Cleanup(test.MockVariableValue(&setting.SSH.Port, 2222)) + t.Cleanup(test.MockVariableValue(&setting.SSH.StartBuiltinServer, false)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GitSSHKnownHosts, []string{ + "[gitea.example.com]:2222 " + testGitSSHPublicKey + " gitea", + })) + + lines, err := gitSSHKnownHostsLines() + require.NoError(t, err) + assert.Equal(t, []string{"[gitea.example.com]:2222 " + testGitSSHPublicKey + " gitea"}, lines) +} + +func TestGitSSHKnownHostsLinesRejectsExternalSSHWithoutConfiguredKnownHosts(t *testing.T) { + t.Cleanup(test.MockVariableValue(&setting.SSH.Domain, "gitea.example.com")) + t.Cleanup(test.MockVariableValue(&setting.SSH.Port, 22)) + t.Cleanup(test.MockVariableValue(&setting.SSH.StartBuiltinServer, false)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GitSSHKnownHosts, []string(nil))) + + _, err := gitSSHKnownHostsLines() + require.ErrorContains(t, err, "known hosts are required") +} + +func TestValidateGitTransports(t *testing.T) { + t.Run("http preferred allows external ssh without known hosts", func(t *testing.T) { + configureGitTransportTestSettings(t, codespace_model.GitProtocolHTTP, false, false, nil) + require.NoError(t, ValidateGitTransports()) + }) + + t.Run("ssh preferred requires known hosts", func(t *testing.T) { + configureGitTransportTestSettings(t, codespace_model.GitProtocolSSH, false, false, nil) + err := ValidateGitTransports() + require.Error(t, err) + assert.ErrorContains(t, err, "known hosts are required") + }) + + t.Run("configured external ssh enables ssh preferred", func(t *testing.T) { + configureGitTransportTestSettings(t, codespace_model.GitProtocolSSH, false, false, []string{ + "gitea.example.com " + testGitSSHPublicKey, + }) + require.NoError(t, ValidateGitTransports()) + }) + + t.Run("ssh disabled rejects ssh preferred", func(t *testing.T) { + configureGitTransportTestSettings(t, codespace_model.GitProtocolSSH, false, true, nil) + err := ValidateGitTransports() + require.Error(t, err) + assert.ErrorContains(t, err, "[server] DISABLE_SSH=true") + }) + + t.Run("http disabled rejects http preferred", func(t *testing.T) { + configureGitTransportTestSettings(t, codespace_model.GitProtocolHTTP, true, false, []string{ + "gitea.example.com " + testGitSSHPublicKey, + }) + err := ValidateGitTransports() + require.Error(t, err) + assert.ErrorContains(t, err, "[repository] DISABLE_HTTP_GIT=true") + }) + + t.Run("no clone transport rejects startup", func(t *testing.T) { + configureGitTransportTestSettings(t, codespace_model.GitProtocolHTTP, true, false, nil) + err := ValidateGitTransports() + require.Error(t, err) + assert.ErrorContains(t, err, "[repository] DISABLE_HTTP_GIT=true") + assert.ErrorContains(t, err, "known hosts are required") + }) +} + +func configureGitTransportTestSettings(t *testing.T, protocol string, disableHTTPGit, disableSSH bool, knownHosts []string) { + t.Helper() + t.Cleanup(test.MockVariableValue(&setting.Repository.DisableHTTPGit, disableHTTPGit)) + t.Cleanup(test.MockVariableValue(&setting.SSH.Disabled, disableSSH)) + t.Cleanup(test.MockVariableValue(&setting.SSH.Domain, "gitea.example.com")) + t.Cleanup(test.MockVariableValue(&setting.SSH.Port, 22)) + t.Cleanup(test.MockVariableValue(&setting.SSH.StartBuiltinServer, false)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GitSSHKnownHosts, knownHosts)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GitProtocol, protocol)) +} + +func configureServiceGitSSHHostKey(t *testing.T) { + t.Helper() + keyPath := filepath.Join(t.TempDir(), "gitea.ed25519") + require.NoError(t, ssh_module.GenKeyPair(keyPath, generate.SSHKeyED25519, 0)) + t.Cleanup(test.MockVariableValue(&setting.SSH.Domain, "gitea.example.com")) + t.Cleanup(test.MockVariableValue(&setting.SSH.Port, 2222)) + t.Cleanup(test.MockVariableValue(&setting.SSH.ServerHostKeys, []string{keyPath})) + t.Cleanup(test.MockVariableValue(&setting.SSH.StartBuiltinServer, true)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GitSSHKnownHosts, []string(nil))) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GitProtocol, codespace_model.GitProtocolSSH)) +} + +func insertActiveCreateCodespaceForGitSSHKey(t *testing.T, managerID int64, codespaceUUID string, operationRVersion int64) { + t.Helper() + insertServiceCodespace(t, managerID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: operationRVersion, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) +} + +func servicePublicKeyWire(t *testing.T, content string) []byte { + t.Helper() + publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(content)) + require.NoError(t, err) + return publicKey.Marshal() +} + +func serviceCanonicalPublicKey(t *testing.T, content string) string { + t.Helper() + publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(content)) + require.NoError(t, err) + return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(publicKey))) +} + +func loadServiceSSHKeyRelation(t *testing.T, codespaceUUID string) *codespace_model.SSHKey { + t.Helper() + row := new(codespace_model.SSHKey) + has, err := db.GetEngine(t.Context()).ID(codespaceUUID).Get(row) + require.NoError(t, err) + require.True(t, has) + return row +} + +func loadServicePublicKey(t *testing.T, keyID int64) *asymkey_model.PublicKey { + t.Helper() + row := new(asymkey_model.PublicKey) + has, err := db.GetEngine(t.Context()).ID(keyID).Get(row) + require.NoError(t, err) + require.True(t, has) + return row +} diff --git a/services/codespace/governance.go b/services/codespace/governance.go new file mode 100644 index 0000000000000..7bf39fe3084f7 --- /dev/null +++ b/services/codespace/governance.go @@ -0,0 +1,369 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" + user_model "gitea.dev/models/user" + "gitea.dev/modules/globallock" + + "xorm.io/builder" +) + +const ( + managerDisplayPending = "pending" + managerDisplayOnline = "online" + managerDisplayRecovering = "recovering" + managerDisplayOffline = "offline" +) + +var ( + // ErrGovernanceNotFound is returned when the Codespace is outside the governance scope. + ErrGovernanceNotFound = errors.New("codespace governance target not found") + // ErrGovernanceStateUnavailable is returned when the requested governance action does not apply. + ErrGovernanceStateUnavailable = errors.New("codespace governance state unavailable") +) + +// GovernanceActionOptions identifies one governance lifecycle request. +type GovernanceActionOptions struct { + CodespaceUUID string + ManagerID int64 + Unassigned bool +} + +// GovernanceListOptions selects one page of Codespaces for a Manager or the unassigned queue. +type GovernanceListOptions struct { + ManagerID int64 + UserID int64 + Unassigned bool + Page int + PageSize int +} + +// GovernanceList contains rows for a governance list page. +type GovernanceList struct { + Rows []*GovernanceView + Total int64 +} + +// GovernanceView contains only the fields non-creator governance pages may show. +type GovernanceView struct { + UUID string + ShortUUID string + DisplayStatus string + StatusSummary string + UpdatedUnix int64 + UserID int64 + UserDisplayName string + RepoID int64 + RepoFullName string + RefName string + ManagerID int64 + ManagerDisplayName string + ManagerRuntimeState string + CanStop bool + CanDelete bool + CanForceDelete bool +} + +// ListGovernanceCodespaces returns one scoped page without exposing creator-only runtime access data. +func ListGovernanceCodespaces(ctx context.Context, opts GovernanceListOptions) (*GovernanceList, error) { + if opts.Page <= 0 || opts.PageSize <= 0 || opts.UserID < 0 || (opts.Unassigned == (opts.ManagerID > 0)) { + return nil, errors.New("invalid Codespace governance list options") + } + condition := builder.Eq{"manager_id": opts.ManagerID} + if opts.Unassigned { + condition = nil + } + var queryCondition builder.Cond = condition + if opts.Unassigned { + queryCondition = builder.Or( + builder.Eq{"manager_id": 0}, + builder.NotIn("manager_id", builder.Select("id").From("codespace_manager")), + ) + } else if opts.UserID > 0 { + queryCondition = builder.And(queryCondition, builder.Eq{"user_id": opts.UserID}) + } + var rows []*codespace_model.Codespace + total, err := db.GetEngine(ctx). + Where(queryCondition). + Desc("updated_unix", "created_unix"). + Limit(opts.PageSize, (opts.Page-1)*opts.PageSize). + FindAndCount(&rows) + if err != nil { + return nil, err + } + result := &GovernanceList{Rows: make([]*GovernanceView, 0, len(rows)), Total: total} + users := make(map[int64]*user_model.User) + repositories := make(map[int64]*repo_model.Repository) + managers := make(map[int64]*codespace_model.Manager) + for _, row := range rows { + view, err := governanceCodespaceView(ctx, row, users, repositories, managers) + if err != nil { + return nil, err + } + result.Rows = append(result.Rows, view) + } + return result, nil +} + +// StopGovernanceCodespace queues a governance stop operation. +func StopGovernanceCodespace(ctx context.Context, opts GovernanceActionOptions) (*LifecycleActionResult, error) { + return applyGovernanceLifecycleAction(ctx, opts, codespace_model.OperationStop) +} + +// DeleteGovernanceCodespace deletes or queues deletion from a governance list. +func DeleteGovernanceCodespace(ctx context.Context, opts GovernanceActionOptions) (*LifecycleActionResult, error) { + return applyGovernanceLifecycleAction(ctx, opts, codespace_model.OperationDelete) +} + +// ForceDeleteCodespace physically deletes one Codespace from the site governance list. +func ForceDeleteCodespace(ctx context.Context, opts GovernanceActionOptions) error { + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return err + } + return globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace, err := loadGovernanceCodespace(ctx, opts.CodespaceUUID) + if err != nil { + return err + } + if err := validateGovernanceTarget(ctx, codespace, opts); err != nil { + return err + } + return deleteCodespaceForFinal(ctx, opts.CodespaceUUID) + }) + }) +} + +func governanceCodespaceView(ctx context.Context, codespace *codespace_model.Codespace, users map[int64]*user_model.User, repositories map[int64]*repo_model.Repository, managers map[int64]*codespace_model.Manager) (*GovernanceView, error) { + manager, managerFound, err := governanceManager(ctx, managers, codespace.ManagerID) + if err != nil { + return nil, err + } + view := &CreatorCodespaceView{ + UUID: codespace.UUID, + Status: codespace.Status, + UpdatedUnix: codespace.UpdatedUnix, + } + applyCreatorDisplayState(ctx, codespace, view, manager, false) + + result := &GovernanceView{ + UUID: codespace.UUID, + ShortUUID: shortCodespaceUUID(codespace.UUID), + DisplayStatus: view.DisplayStatus, + StatusSummary: view.StatusSummary, + UpdatedUnix: codespace.UpdatedUnix, + UserID: codespace.UserID, + RepoID: codespace.RepoID, + RefName: codespace.RefName, + ManagerID: codespace.ManagerID, + } + if displayName, err := governanceUserDisplayName(ctx, users, codespace.UserID); err != nil { + return nil, err + } else { + result.UserDisplayName = displayName + } + if fullName, err := governanceRepositoryFullName(ctx, repositories, codespace.RepoID); err != nil { + return nil, err + } else { + result.RepoFullName = fullName + } + if managerFound { + applyGovernanceManagerFields(result, manager) + } else { + applyGovernanceManagerFields(result, nil) + } + applyGovernanceActions(result) + return result, nil +} + +func governanceRepositoryFullName(ctx context.Context, cache map[int64]*repo_model.Repository, repoID int64) (string, error) { + if repoID <= 0 { + return "", nil + } + if repo, ok := cache[repoID]; ok { + if repo == nil { + return "", nil + } + return repo.FullName(), nil + } + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if repo_model.IsErrRepoNotExist(err) { + cache[repoID] = nil + return "", nil + } + if err != nil { + return "", err + } + cache[repoID] = repo + return repo.FullName(), nil +} + +func applyGovernanceManagerFields(view *GovernanceView, manager *codespace_model.Manager) { + if manager == nil { + view.ManagerRuntimeState = managerDisplayPending + return + } + view.ManagerDisplayName = manager.Name + if view.ManagerDisplayName == "" { + view.ManagerDisplayName = fmt.Sprintf("Manager %d", manager.ID) + } + switch { + case manager.RuntimeState == codespace_model.ManagerRuntimeStateOnline && !isManagerOffline(manager): + view.ManagerRuntimeState = managerDisplayOnline + case manager.RuntimeState == codespace_model.ManagerRuntimeStateRecovering && !isManagerOffline(manager): + view.ManagerRuntimeState = managerDisplayRecovering + default: + view.ManagerRuntimeState = managerDisplayOffline + } +} + +func applyGovernanceActions(view *GovernanceView) { + switch view.DisplayStatus { + case DisplayRunning, DisplayRecovering, DisplayMetadataRebuilding: + view.CanStop = true + view.CanDelete = true + case DisplayQueued, DisplayBooting, DisplayStopping, DisplayStopped, DisplayResuming, DisplayFailed: + view.CanDelete = true + } + if view.DisplayStatus == DisplayDeleting { + view.CanDelete = false + } + view.CanForceDelete = true +} + +func applyGovernanceLifecycleAction(ctx context.Context, opts GovernanceActionOptions, operationType string) (*LifecycleActionResult, error) { + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return nil, err + } + + var result *LifecycleActionResult + err := globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace, err := loadGovernanceCodespace(ctx, opts.CodespaceUUID) + if err != nil { + return err + } + if err := validateGovernanceTarget(ctx, codespace, opts); err != nil { + return err + } + manager, _, err := governanceManager(ctx, make(map[int64]*codespace_model.Manager), codespace.ManagerID) + if err != nil { + return err + } + view := &CreatorCodespaceView{} + applyCreatorDisplayState(ctx, codespace, view, manager, false) + governanceView := &GovernanceView{DisplayStatus: view.DisplayStatus} + applyGovernanceActions(governanceView) + switch operationType { + case codespace_model.OperationStop: + if !governanceView.CanStop { + return ErrGovernanceStateUnavailable + } + result, err = applyStopAction(ctx, codespace, time.Now().Unix()) + case codespace_model.OperationDelete: + if !governanceView.CanDelete { + return ErrGovernanceStateUnavailable + } + result, err = applyDeleteAction(ctx, codespace, time.Now().Unix()) + default: + err = fmt.Errorf("unsupported governance operation %q", operationType) + } + if errors.Is(err, ErrLifecycleActionStateUnavailable) { + return ErrGovernanceStateUnavailable + } + return err + }) + }) + return result, err +} + +func validateGovernanceTarget(ctx context.Context, codespace *codespace_model.Codespace, opts GovernanceActionOptions) error { + if opts.ManagerID > 0 { + if opts.Unassigned || codespace.ManagerID != opts.ManagerID { + return ErrGovernanceNotFound + } + return nil + } + if !opts.Unassigned { + return nil + } + _, found, err := governanceManager(ctx, make(map[int64]*codespace_model.Manager), codespace.ManagerID) + if err != nil { + return err + } + if codespace.ManagerID != 0 && found { + return ErrGovernanceNotFound + } + return nil +} + +func loadGovernanceCodespace(ctx context.Context, codespaceUUID string) (*codespace_model.Codespace, error) { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(codespaceUUID).Get(codespace) + if err != nil { + return nil, err + } + if !has { + return nil, ErrGovernanceNotFound + } + return codespace, nil +} + +func governanceUserDisplayName(ctx context.Context, cache map[int64]*user_model.User, userID int64) (string, error) { + if userID <= 0 { + return "", nil + } + if user, ok := cache[userID]; ok { + if user == nil { + return "", nil + } + return user.DisplayName(), nil + } + user, err := user_model.GetUserByID(ctx, userID) + if user_model.IsErrUserNotExist(err) { + cache[userID] = nil + return "", nil + } + if err != nil { + return "", err + } + cache[userID] = user + return user.DisplayName(), nil +} + +func governanceManager(ctx context.Context, cache map[int64]*codespace_model.Manager, managerID int64) (*codespace_model.Manager, bool, error) { + if managerID <= 0 { + return nil, false, nil + } + if manager, ok := cache[managerID]; ok { + return manager, manager != nil, nil + } + manager := new(codespace_model.Manager) + has, err := db.GetEngine(ctx).ID(managerID).Get(manager) + if err != nil { + return nil, false, err + } + if !has { + cache[managerID] = nil + return nil, false, nil + } + cache[managerID] = manager + return manager, true, nil +} + +func shortCodespaceUUID(codespaceUUID string) string { + if len(codespaceUUID) <= 8 { + return codespaceUUID + } + return codespaceUUID[:8] +} diff --git a/services/codespace/governance_test.go b/services/codespace/governance_test.go new file mode 100644 index 0000000000000..51489e85ade11 --- /dev/null +++ b/services/codespace/governance_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListGovernanceCodespacesAndActions(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + runningUUID := "31313131-3131-4131-8131-313131313131" + unboundUUID := "33333333-3333-4333-8333-333333333333" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: runningUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 31, + }) + insertServiceCodespace(t, 0, &codespace_model.Codespace{ + UUID: unboundUUID, + Status: codespace_model.StatusCreating, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + }) + + list, err := ListGovernanceCodespaces(t.Context(), GovernanceListOptions{ + ManagerID: manager.ID, + Page: 1, + PageSize: 30, + }) + require.NoError(t, err) + require.Len(t, list.Rows, 1) + assert.EqualValues(t, 1, list.Total) + rows := governanceRowsByUUID(list.Rows) + assert.True(t, rows[runningUUID].CanStop) + assert.True(t, rows[runningUUID].CanForceDelete) + assert.Equal(t, "user2/repo2", rows[runningUUID].RepoFullName) + assert.Equal(t, "main", rows[runningUUID].RefName) + + unassigned, err := ListGovernanceCodespaces(t.Context(), GovernanceListOptions{ + Unassigned: true, + Page: 1, + PageSize: 30, + }) + require.NoError(t, err) + require.Len(t, unassigned.Rows, 1) + assert.Equal(t, unboundUUID, unassigned.Rows[0].UUID) + assert.True(t, unassigned.Rows[0].CanDelete) + assert.True(t, unassigned.Rows[0].CanForceDelete) + assert.Equal(t, managerDisplayPending, unassigned.Rows[0].ManagerRuntimeState) + + _, err = StopGovernanceCodespace(t.Context(), GovernanceActionOptions{CodespaceUUID: runningUUID}) + require.NoError(t, err) + row := loadServiceCodespace(t, runningUUID) + assert.Equal(t, codespace_model.OperationStop, row.OperationType) + assert.Equal(t, codespace_model.OperationStatusQueued, row.OperationStatus) + assert.EqualValues(t, 32, row.OperationRVersion) +} + +func TestGovernanceActionRequiresListedManager(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + otherManager := insertServiceManager(t) + codespaceUUID := "34343434-3434-4434-8434-343434343434" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 34, + }) + + _, err := StopGovernanceCodespace(t.Context(), GovernanceActionOptions{ + CodespaceUUID: codespaceUUID, + ManagerID: otherManager.ID, + }) + require.ErrorIs(t, err, ErrGovernanceNotFound) + assert.Equal(t, codespace_model.StatusRunning, loadServiceCodespace(t, codespaceUUID).Status) +} + +func TestForceDeleteCodespaceRemovesLocalState(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "36363636-3636-4636-8636-363636363636" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusDeleting, + OperationRVersion: 36, + OperationType: codespace_model.OperationDelete, + OperationStatus: codespace_model.OperationStatusRunning, + }) + insertServiceCredentials(t, codespaceUUID) + + err := ForceDeleteCodespace(t.Context(), GovernanceActionOptions{CodespaceUUID: codespaceUUID}) + require.NoError(t, err) + assertServiceNotExists(t, new(codespace_model.Codespace), "uuid = ?", codespaceUUID) + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", codespaceUUID) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", codespaceUUID) +} + +func governanceRowsByUUID(rows []*GovernanceView) map[string]*GovernanceView { + result := make(map[string]*GovernanceView, len(rows)) + for _, row := range rows { + result[row.UUID] = row + } + return result +} diff --git a/services/codespace/idle_stop.go b/services/codespace/idle_stop.go new file mode 100644 index 0000000000000..0a80d53625386 --- /dev/null +++ b/services/codespace/idle_stop.go @@ -0,0 +1,178 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/modules/globallock" +) + +var ( + // ErrRequestIdleStopNotFound is returned when the Codespace no longer exists. + ErrRequestIdleStopNotFound = errors.New("codespace not found") + // ErrRequestIdleStopManagerMismatch is returned when the Codespace is bound to another Manager. + ErrRequestIdleStopManagerMismatch = errors.New("codespace belongs to another manager") + // ErrRequestIdleStopManagerUnavailable is returned when the authenticated Manager is not usable. + ErrRequestIdleStopManagerUnavailable = errors.New("manager is not online") + // ErrRequestIdleStopVersionExhausted is returned when operation_rversion cannot advance. + ErrRequestIdleStopVersionExhausted = errors.New("codespace operation version exhausted") +) + +// RequestIdleStopOptions contains one Manager idle-stop authorization request. +type RequestIdleStopOptions struct { + CodespaceUUID string + ObservedAutoStopEnabled bool + ObservedIdleTimeoutSeconds int64 + ObservedInteractionGeneration int64 +} + +// RequestIdleStop creates or confirms a queued idle stop after current setting validation. +func RequestIdleStop(ctx context.Context, manager *codespace_model.Manager, opts RequestIdleStopOptions) (*codespacev1.RequestIdleStopResponse, error) { + if manager == nil || manager.ID <= 0 { + return nil, errors.New("manager is required") + } + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return nil, err + } + if err := validateObservedRuntimeSettings(opts); err != nil { + return nil, err + } + + var response *codespacev1.RequestIdleStopResponse + err := globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + currentManager, err := loadCodespaceManager(ctx, manager.ID) + if err != nil { + return err + } + if currentManager.RuntimeState != codespace_model.ManagerRuntimeStateOnline || isManagerOffline(currentManager) { + return ErrRequestIdleStopManagerUnavailable + } + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceUUID).Get(codespace) + if err != nil { + return err + } + if !has { + return ErrRequestIdleStopNotFound + } + if codespace.ManagerID != manager.ID { + return ErrRequestIdleStopManagerMismatch + } + + if isQueuedIdleStop(codespace) { + response = idleStopPendingResponse(codespace.OperationRVersion) + return nil + } + if hasActiveOperation(codespace) { + response = notApplicableIdleStop(codespacev1.IdleStopNotApplicableReason_IDLE_STOP_NOT_APPLICABLE_REASON_OPERATION_CONFLICT) + return nil + } + switch codespace.Status { + case codespace_model.StatusStopped: + response = notApplicableIdleStop(codespacev1.IdleStopNotApplicableReason_IDLE_STOP_NOT_APPLICABLE_REASON_ALREADY_STOPPED) + return nil + case codespace_model.StatusRunning: + default: + response = notApplicableIdleStop(codespacev1.IdleStopNotApplicableReason_IDLE_STOP_NOT_APPLICABLE_REASON_STATE_UNAVAILABLE) + return nil + } + + settings := effectiveRuntimeSettings(codespace) + if !settings.AutoStopEnabled || settingsChanged(settings, opts) { + response = &codespacev1.RequestIdleStopResponse{ + Outcome: &codespacev1.RequestIdleStopResponse_ObservationChanged{ + ObservationChanged: &codespacev1.IdleStopObservationChanged{RuntimeSettings: runtimeSettingsMessage(settings)}, + }, + } + return nil + } + nextVersion, err := codespace_model.NextVersion(codespace.OperationRVersion) + if err != nil { + return ErrRequestIdleStopVersionExhausted + } + now := time.Now().Unix() + codespace.OperationRVersion = nextVersion + codespace.OperationType = codespace_model.OperationStop + codespace.OperationStatus = codespace_model.OperationStatusQueued + codespace.OperationTrigger = codespace_model.OperationTriggerIdle + codespace.OperationCreatedUnix = now + codespace.OperationStartedUnix = 0 + codespace.OperationDeadlineUnix = 0 + codespace.UpdatedUnix = now + if _, err := db.GetEngine(ctx).ID(codespace.UUID).Cols( + "operation_r_version", + "operation_type", + "operation_status", + "operation_trigger", + "operation_created_unix", + "operation_started_unix", + "operation_deadline_unix", + "updated_unix", + ).Update(codespace); err != nil { + return err + } + response = idleStopPendingResponse(nextVersion) + return nil + }) + }) + if err != nil { + return nil, err + } + return response, nil +} + +func validateObservedRuntimeSettings(opts RequestIdleStopOptions) error { + if opts.ObservedInteractionGeneration < 0 { + return errors.New("observed_interaction_generation must not be negative") + } + if opts.ObservedAutoStopEnabled { + if opts.ObservedIdleTimeoutSeconds <= 0 { + return errors.New("observed_idle_timeout_seconds must be positive when auto stop is enabled") + } + return nil + } + if opts.ObservedIdleTimeoutSeconds != 0 { + return errors.New("observed_idle_timeout_seconds must be zero when auto stop is disabled") + } + return nil +} + +func isQueuedIdleStop(codespace *codespace_model.Codespace) bool { + return codespace.OperationType == codespace_model.OperationStop && + codespace.OperationStatus == codespace_model.OperationStatusQueued && + codespace.OperationTrigger == codespace_model.OperationTriggerIdle +} + +func idleStopPendingResponse(operationRVersion int64) *codespacev1.RequestIdleStopResponse { + return &codespacev1.RequestIdleStopResponse{ + Outcome: &codespacev1.RequestIdleStopResponse_Pending{ + Pending: &codespacev1.IdleStopPending{OperationRversion: operationRVersion}, + }, + } +} + +func notApplicableIdleStop(reason codespacev1.IdleStopNotApplicableReason) *codespacev1.RequestIdleStopResponse { + return &codespacev1.RequestIdleStopResponse{ + Outcome: &codespacev1.RequestIdleStopResponse_NotApplicable{ + NotApplicable: &codespacev1.IdleStopNotApplicable{Reason: reason}, + }, + } +} + +func settingsChanged(settings RuntimeSettings, opts RequestIdleStopOptions) bool { + return settings.AutoStopEnabled != opts.ObservedAutoStopEnabled || + settings.IdleTimeoutSeconds != opts.ObservedIdleTimeoutSeconds || + settings.InteractionGeneration != opts.ObservedInteractionGeneration +} + +func codespaceStateLockKey(codespaceUUID string) string { + return "codespace_interaction_" + codespaceUUID +} diff --git a/services/codespace/idle_stop_test.go b/services/codespace/idle_stop_test.go new file mode 100644 index 0000000000000..a3268b5dd6364 --- /dev/null +++ b/services/codespace/idle_stop_test.go @@ -0,0 +1,269 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "math" + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRequestIdleStopCreatesAndConfirmsPending(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "51515151-5151-4515-8515-515151515151" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 6, + InteractionGeneration: 3, + }) + + result, err := RequestIdleStop(t.Context(), manager, RequestIdleStopOptions{ + CodespaceUUID: codespaceUUID, + ObservedAutoStopEnabled: true, + ObservedIdleTimeoutSeconds: 1800, + ObservedInteractionGeneration: 3, + }) + require.NoError(t, err) + assert.EqualValues(t, 7, result.GetPending().GetOperationRversion()) + row := loadServiceCodespace(t, codespaceUUID) + assert.EqualValues(t, 7, row.OperationRVersion) + assert.Equal(t, codespace_model.OperationStop, row.OperationType) + assert.Equal(t, codespace_model.OperationStatusQueued, row.OperationStatus) + assert.Equal(t, codespace_model.OperationTriggerIdle, row.OperationTrigger) + assert.Positive(t, row.UpdatedUnix) + + again, err := RequestIdleStop(t.Context(), manager, RequestIdleStopOptions{ + CodespaceUUID: codespaceUUID, + ObservedAutoStopEnabled: true, + ObservedIdleTimeoutSeconds: 1800, + ObservedInteractionGeneration: 3, + }) + require.NoError(t, err) + assert.EqualValues(t, 7, again.GetPending().GetOperationRversion()) +} + +func TestRequestIdleStopReturnsObservationChanged(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "52525252-5252-4525-8525-525252525252" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 8, + InteractionGeneration: 4, + AutoStopMode: codespace_model.AutoStopModeCustom, + AutoStopTimeoutSeconds: 600, + }) + + result, err := RequestIdleStop(t.Context(), manager, RequestIdleStopOptions{ + CodespaceUUID: codespaceUUID, + ObservedAutoStopEnabled: true, + ObservedIdleTimeoutSeconds: 1800, + ObservedInteractionGeneration: 4, + }) + require.NoError(t, err) + settings := result.GetObservationChanged().GetRuntimeSettings() + assert.True(t, settings.GetAutoStopEnabled()) + assert.EqualValues(t, 600, settings.GetIdleTimeoutSeconds()) + assert.EqualValues(t, 4, settings.GetInteractionGeneration()) + assert.Empty(t, loadServiceCodespace(t, codespaceUUID).OperationType) +} + +func TestRequestIdleStopDisabledReturnsObservationChangedAndKeepsPending(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + defer test.MockVariableValue(&setting.Codespace.Enabled, false)() + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + runningUUID := "56565656-5656-4565-8565-565656565656" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: runningUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 11, + InteractionGeneration: 5, + }) + + result, err := RequestIdleStop(t.Context(), manager, RequestIdleStopOptions{ + CodespaceUUID: runningUUID, + ObservedAutoStopEnabled: true, + ObservedIdleTimeoutSeconds: 1800, + ObservedInteractionGeneration: 5, + }) + require.NoError(t, err) + settings := result.GetObservationChanged().GetRuntimeSettings() + assert.False(t, settings.GetAutoStopEnabled()) + assert.Zero(t, settings.GetIdleTimeoutSeconds()) + assert.EqualValues(t, 5, settings.GetInteractionGeneration()) + row := loadServiceCodespace(t, runningUUID) + assert.Empty(t, row.OperationType) + assert.EqualValues(t, 11, row.OperationRVersion) + + pendingUUID := "57575757-5757-4575-8575-575757575757" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: pendingUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 12, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerIdle, + OperationCreatedUnix: time.Now().Unix(), + }) + pending, err := RequestIdleStop(t.Context(), manager, RequestIdleStopOptions{ + CodespaceUUID: pendingUUID, + ObservedAutoStopEnabled: true, + ObservedIdleTimeoutSeconds: 1800, + ObservedInteractionGeneration: 0, + }) + require.NoError(t, err) + assert.EqualValues(t, 12, pending.GetPending().GetOperationRversion()) +} + +func TestRequestIdleStopNotApplicableAndVersionExhausted(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + conflictUUID := "53535353-5353-4535-8535-535353535351" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: conflictUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 8, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + }) + result, err := RequestIdleStop(t.Context(), manager, RequestIdleStopOptions{ + CodespaceUUID: conflictUUID, + ObservedAutoStopEnabled: true, + ObservedIdleTimeoutSeconds: 1800, + ObservedInteractionGeneration: 0, + }) + require.NoError(t, err) + assert.Equal(t, codespacev1.IdleStopNotApplicableReason_IDLE_STOP_NOT_APPLICABLE_REASON_OPERATION_CONFLICT, result.GetNotApplicable().GetReason()) + + stoppedUUID := "53535353-5353-4535-8535-535353535353" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: stoppedUUID, + Status: codespace_model.StatusStopped, + OperationRVersion: 9, + }) + result, err = RequestIdleStop(t.Context(), manager, RequestIdleStopOptions{ + CodespaceUUID: stoppedUUID, + ObservedAutoStopEnabled: true, + ObservedIdleTimeoutSeconds: 1800, + ObservedInteractionGeneration: 0, + }) + require.NoError(t, err) + assert.Equal(t, codespacev1.IdleStopNotApplicableReason_IDLE_STOP_NOT_APPLICABLE_REASON_ALREADY_STOPPED, result.GetNotApplicable().GetReason()) + + creatingUUID := "53535353-5353-4535-8535-535353535354" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: creatingUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 9, + }) + result, err = RequestIdleStop(t.Context(), manager, RequestIdleStopOptions{ + CodespaceUUID: creatingUUID, + ObservedAutoStopEnabled: true, + ObservedIdleTimeoutSeconds: 1800, + ObservedInteractionGeneration: 0, + }) + require.NoError(t, err) + assert.Equal(t, codespacev1.IdleStopNotApplicableReason_IDLE_STOP_NOT_APPLICABLE_REASON_STATE_UNAVAILABLE, result.GetNotApplicable().GetReason()) + + exhaustedUUID := "54545454-5454-4545-8545-545454545454" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: exhaustedUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: math.MaxInt64, + }) + _, err = RequestIdleStop(t.Context(), manager, RequestIdleStopOptions{ + CodespaceUUID: exhaustedUUID, + ObservedAutoStopEnabled: true, + ObservedIdleTimeoutSeconds: 1800, + ObservedInteractionGeneration: 0, + }) + require.ErrorIs(t, err, ErrRequestIdleStopVersionExhausted) + assert.Empty(t, loadServiceCodespace(t, exhaustedUUID).OperationType) +} + +func TestRequestIdleStopCreatesNewVersionAfterQueuedIdleTimeout(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "56565656-5656-4565-8565-565656565657" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 12, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerIdle, + OperationCreatedUnix: time.Now().Add(-setting.Codespace.QueueTimeout - time.Second).Unix(), + InteractionGeneration: 6, + }) + + fetch, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ + CleanupCapacityAvailable: 1, + }) + require.NoError(t, err) + assert.Empty(t, fetch.Operations) + row := loadServiceCodespace(t, codespaceUUID) + require.Equal(t, codespace_model.StatusRunning, row.Status) + require.Empty(t, row.OperationType) + require.EqualValues(t, 12, row.OperationRVersion) + + result, err := RequestIdleStop(t.Context(), manager, RequestIdleStopOptions{ + CodespaceUUID: codespaceUUID, + ObservedAutoStopEnabled: true, + ObservedIdleTimeoutSeconds: int64(setting.Codespace.AutoStopDefaultTimeout / time.Second), + ObservedInteractionGeneration: 6, + }) + require.NoError(t, err) + assert.EqualValues(t, 13, result.GetPending().GetOperationRversion()) + row = loadServiceCodespace(t, codespaceUUID) + assert.Equal(t, codespace_model.OperationStop, row.OperationType) + assert.Equal(t, codespace_model.OperationStatusQueued, row.OperationStatus) + assert.Equal(t, codespace_model.OperationTriggerIdle, row.OperationTrigger) +} + +func TestRequestIdleStopRejectsManagerMismatch(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + otherManager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + markServiceManagerOnline(t, otherManager, `[{"tag":"default"}]`) + codespaceUUID := "55555555-5555-4555-8555-555555555555" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 10, + }) + + _, err := RequestIdleStop(t.Context(), otherManager, RequestIdleStopOptions{ + CodespaceUUID: codespaceUUID, + ObservedAutoStopEnabled: true, + ObservedIdleTimeoutSeconds: int64((30 * time.Minute) / time.Second), + ObservedInteractionGeneration: 0, + }) + require.ErrorIs(t, err, ErrRequestIdleStopManagerMismatch) +} diff --git a/services/codespace/interaction.go b/services/codespace/interaction.go new file mode 100644 index 0000000000000..9c18d2938c805 --- /dev/null +++ b/services/codespace/interaction.go @@ -0,0 +1,196 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/modules/globallock" + "gitea.dev/modules/setting" +) + +var ( + // ErrInteractionNotFound is returned when the Codespace no longer exists. + ErrInteractionNotFound = errors.New("codespace not found") + // ErrInteractionPermissionDenied is returned when the user is not the Codespace creator. + ErrInteractionPermissionDenied = errors.New("codespace permission denied") + // ErrInteractionStateUnavailable is returned when the lifecycle state cannot accept the request. + ErrInteractionStateUnavailable = errors.New("codespace state unavailable") + // ErrInteractionVersionExhausted is returned when interaction_generation cannot advance. + ErrInteractionVersionExhausted = errors.New("codespace interaction version exhausted") + // ErrInteractionInvalidArgument is returned when user-submitted interaction options are invalid. + ErrInteractionInvalidArgument = errors.New("codespace interaction invalid argument") +) + +// ContinueCodespaceOptions identifies one creator keep-alive action. +type ContinueCodespaceOptions struct { + UserID int64 + CodespaceUUID string +} + +// ContinueCodespaceResult contains the new interaction generation. +type ContinueCodespaceResult struct { + InteractionGeneration int64 +} + +// UpdateAutoStopOptions contains one creator auto-stop settings update. +type UpdateAutoStopOptions struct { + UserID int64 + CodespaceUUID string + Mode string + CustomTimeoutSeconds int64 +} + +// UpdateAutoStopResult contains the persisted and effective runtime settings. +type UpdateAutoStopResult struct { + Mode string + CustomTimeoutSeconds int64 + RuntimeSettings RuntimeSettings +} + +// ContinueCodespace records creator activity and cancels a queued idle stop. +func ContinueCodespace(ctx context.Context, opts ContinueCodespaceOptions) (*ContinueCodespaceResult, error) { + if !setting.Codespace.Enabled { + return nil, ErrInteractionStateUnavailable + } + if err := validateCreatorInteractionOptions(opts.UserID, opts.CodespaceUUID); err != nil { + return nil, err + } + + var result *ContinueCodespaceResult + err := globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace, err := loadCreatorCodespace(ctx, opts.UserID, opts.CodespaceUUID) + if err != nil { + return err + } + if codespace.Status != codespace_model.StatusRunning || (hasActiveOperation(codespace) && !isQueuedIdleStop(codespace)) { + return ErrInteractionStateUnavailable + } + now := time.Now().Unix() + nextGeneration, err := advanceCodespaceInteraction(ctx, codespace, now) + if err != nil { + if err == errInteractionVersionExhausted { + return ErrInteractionVersionExhausted + } + return err + } + result = &ContinueCodespaceResult{InteractionGeneration: nextGeneration} + return nil + }) + }) + if err != nil { + return nil, err + } + return result, nil +} + +// UpdateAutoStop saves creator auto-stop settings and cancels stale queued idle stops. +func UpdateAutoStop(ctx context.Context, opts UpdateAutoStopOptions) (*UpdateAutoStopResult, error) { + mode, customTimeoutSeconds, err := normalizeAutoStopOptions(opts.Mode, opts.CustomTimeoutSeconds) + if err != nil { + return nil, err + } + if err := validateCreatorInteractionOptions(opts.UserID, opts.CodespaceUUID); err != nil { + return nil, err + } + + var result *UpdateAutoStopResult + err = globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace, err := loadCreatorCodespace(ctx, opts.UserID, opts.CodespaceUUID) + if err != nil { + return err + } + if codespace.Status != codespace_model.StatusRunning && codespace.Status != codespace_model.StatusStopped { + return ErrInteractionStateUnavailable + } + oldSettings := effectiveRuntimeSettings(codespace) + changed := codespace.AutoStopMode != mode || codespace.AutoStopTimeoutSeconds != customTimeoutSeconds + codespace.AutoStopMode = mode + codespace.AutoStopTimeoutSeconds = customTimeoutSeconds + newSettings := effectiveRuntimeSettings(codespace) + + cols := []string{"auto_stop_mode", "auto_stop_timeout_seconds"} + if settingsRuntimePolicyChanged(oldSettings, newSettings) && isQueuedIdleStop(codespace) { + codespace.UpdatedUnix = time.Now().Unix() + clearActiveOperation(codespace) + cols = append(cols, + "operation_type", + "operation_status", + "operation_trigger", + "operation_created_unix", + "operation_started_unix", + "operation_deadline_unix", + "updated_unix", + ) + } + if changed || len(cols) > 2 { + if _, err := db.GetEngine(ctx).ID(codespace.UUID).Cols(cols...).Update(codespace); err != nil { + return err + } + } + result = &UpdateAutoStopResult{ + Mode: codespace.AutoStopMode, + CustomTimeoutSeconds: codespace.AutoStopTimeoutSeconds, + RuntimeSettings: newSettings, + } + return nil + }) + }) + if err != nil { + return nil, err + } + return result, nil +} + +func validateCreatorInteractionOptions(userID int64, codespaceUUID string) error { + if userID <= 0 { + return errors.New("user_id must be positive") + } + return codespace_model.ValidateUUID(codespaceUUID) +} + +func loadCreatorCodespace(ctx context.Context, userID int64, codespaceUUID string) (*codespace_model.Codespace, error) { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(codespaceUUID).Get(codespace) + if err != nil { + return nil, err + } + if !has { + return nil, ErrInteractionNotFound + } + if codespace.UserID != userID { + return nil, ErrInteractionPermissionDenied + } + return codespace, nil +} + +func normalizeAutoStopOptions(mode string, customTimeoutSeconds int64) (string, int64, error) { + switch mode { + case codespace_model.AutoStopModeDefault, "": + return codespace_model.AutoStopModeDefault, 0, nil + case codespace_model.AutoStopModeNever: + return codespace_model.AutoStopModeNever, 0, nil + case codespace_model.AutoStopModeCustom: + minSeconds := int64(setting.Codespace.AutoStopMinTimeout / time.Second) + maxSeconds := int64(setting.Codespace.AutoStopMaxTimeout / time.Second) + if customTimeoutSeconds < minSeconds || customTimeoutSeconds > maxSeconds { + return "", 0, fmt.Errorf("%w: custom timeout must be between %d and %d seconds", ErrInteractionInvalidArgument, minSeconds, maxSeconds) + } + return mode, customTimeoutSeconds, nil + default: + return "", 0, fmt.Errorf("%w: invalid auto-stop mode", ErrInteractionInvalidArgument) + } +} + +func settingsRuntimePolicyChanged(oldSettings, newSettings RuntimeSettings) bool { + return oldSettings.AutoStopEnabled != newSettings.AutoStopEnabled || + oldSettings.IdleTimeoutSeconds != newSettings.IdleTimeoutSeconds +} diff --git a/services/codespace/interaction_test.go b/services/codespace/interaction_test.go new file mode 100644 index 0000000000000..3eb9bde1eb6d3 --- /dev/null +++ b/services/codespace/interaction_test.go @@ -0,0 +1,218 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "math" + "testing" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestContinueCodespaceCancelsQueuedIdleStop(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "56565656-5656-4565-8565-565656565656" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 11, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerIdle, + OperationCreatedUnix: time.Now().Unix(), + InteractionGeneration: 4, + }) + + result, err := ContinueCodespace(t.Context(), ContinueCodespaceOptions{ + UserID: 1, + CodespaceUUID: codespaceUUID, + }) + require.NoError(t, err) + assert.EqualValues(t, 5, result.InteractionGeneration) + row := loadServiceCodespace(t, codespaceUUID) + assert.EqualValues(t, 5, row.InteractionGeneration) + assert.Empty(t, row.OperationType) + assert.Empty(t, row.OperationStatus) + assert.Empty(t, row.OperationTrigger) + assert.Positive(t, row.LastActiveUnix) +} + +func TestContinueCodespaceKeepsLifecycleUpdatedUnixWithoutIdleStop(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "56565656-5656-4565-8565-565656565657" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 11, + InteractionGeneration: 4, + }) + + result, err := ContinueCodespace(t.Context(), ContinueCodespaceOptions{ + UserID: 1, + CodespaceUUID: codespaceUUID, + }) + require.NoError(t, err) + assert.EqualValues(t, 5, result.InteractionGeneration) + row := loadServiceCodespace(t, codespaceUUID) + assert.EqualValues(t, 1, row.UpdatedUnix) + assert.Positive(t, row.LastActiveUnix) +} + +func TestContinueCodespaceRejectsRunningStopAndVersionExhausted(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + runningStopUUID := "57575757-5757-4575-8575-575757575757" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: runningStopUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 12, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerIdle, + OperationCreatedUnix: time.Now().Unix(), + OperationStartedUnix: time.Now().Unix(), + OperationDeadlineUnix: time.Now().Add(time.Minute).Unix(), + }) + _, err := ContinueCodespace(t.Context(), ContinueCodespaceOptions{ + UserID: 1, + CodespaceUUID: runningStopUUID, + }) + require.ErrorIs(t, err, ErrInteractionStateUnavailable) + + exhaustedUUID := "58585858-5858-4585-8585-585858585858" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: exhaustedUUID, + Status: codespace_model.StatusRunning, + InteractionGeneration: math.MaxInt64, + }) + _, err = ContinueCodespace(t.Context(), ContinueCodespaceOptions{ + UserID: 1, + CodespaceUUID: exhaustedUUID, + }) + require.ErrorIs(t, err, ErrInteractionVersionExhausted) +} + +func TestUpdateAutoStopCancelsQueuedIdleOnlyWhenRuntimePolicyChanges(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + changedUUID := "59595959-5959-4595-8595-595959595959" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: changedUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 13, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerIdle, + OperationCreatedUnix: time.Now().Unix(), + }) + result, err := UpdateAutoStop(t.Context(), UpdateAutoStopOptions{ + UserID: 1, + CodespaceUUID: changedUUID, + Mode: codespace_model.AutoStopModeCustom, + CustomTimeoutSeconds: int64((10 * time.Minute) / time.Second), + }) + require.NoError(t, err) + assert.Equal(t, codespace_model.AutoStopModeCustom, result.Mode) + assert.EqualValues(t, 600, result.CustomTimeoutSeconds) + assert.EqualValues(t, 600, result.RuntimeSettings.IdleTimeoutSeconds) + row := loadServiceCodespace(t, changedUUID) + assert.Empty(t, row.OperationType) + assert.Empty(t, row.OperationStatus) + assert.Greater(t, row.UpdatedUnix, int64(1)) + + samePolicyUUID := "60606060-6060-4606-8606-606060606060" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: samePolicyUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 14, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerIdle, + OperationCreatedUnix: time.Now().Unix(), + }) + _, err = UpdateAutoStop(t.Context(), UpdateAutoStopOptions{ + UserID: 1, + CodespaceUUID: samePolicyUUID, + Mode: codespace_model.AutoStopModeCustom, + CustomTimeoutSeconds: int64(setting.Codespace.AutoStopDefaultTimeout / time.Second), + }) + require.NoError(t, err) + row = loadServiceCodespace(t, samePolicyUUID) + assert.Equal(t, codespace_model.OperationStop, row.OperationType) + assert.Equal(t, codespace_model.OperationStatusQueued, row.OperationStatus) + assert.Equal(t, codespace_model.OperationTriggerIdle, row.OperationTrigger) +} + +func TestUpdateAutoStopKeepsLifecycleUpdatedUnixWithoutIdleStopCancellation(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "60606060-6060-4606-8606-606060606061" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + }) + + _, err := UpdateAutoStop(t.Context(), UpdateAutoStopOptions{ + UserID: 1, + CodespaceUUID: codespaceUUID, + Mode: codespace_model.AutoStopModeNever, + }) + require.NoError(t, err) + row := loadServiceCodespace(t, codespaceUUID) + assert.Equal(t, codespace_model.AutoStopModeNever, row.AutoStopMode) + assert.EqualValues(t, 1, row.UpdatedUnix) +} + +func TestUpdateAutoStopValidationAndState(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + stoppedUUID := "61616161-6161-4616-8616-616161616161" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: stoppedUUID, + Status: codespace_model.StatusStopped, + }) + _, err := UpdateAutoStop(t.Context(), UpdateAutoStopOptions{ + UserID: 1, + CodespaceUUID: stoppedUUID, + Mode: codespace_model.AutoStopModeNever, + }) + require.NoError(t, err) + row := loadServiceCodespace(t, stoppedUUID) + assert.Equal(t, codespace_model.AutoStopModeNever, row.AutoStopMode) + assert.Zero(t, row.AutoStopTimeoutSeconds) + + _, err = UpdateAutoStop(t.Context(), UpdateAutoStopOptions{ + UserID: 1, + CodespaceUUID: stoppedUUID, + Mode: codespace_model.AutoStopModeCustom, + CustomTimeoutSeconds: int64((setting.Codespace.AutoStopMinTimeout / time.Second) - 1), + }) + require.Error(t, err) + + creatingUUID := "62626262-6262-4626-8626-626262626262" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: creatingUUID, + Status: codespace_model.StatusCreating, + }) + _, err = UpdateAutoStop(t.Context(), UpdateAutoStopOptions{ + UserID: 1, + CodespaceUUID: creatingUUID, + Mode: codespace_model.AutoStopModeNever, + }) + require.ErrorIs(t, err, ErrInteractionStateUnavailable) +} diff --git a/services/codespace/inventory.go b/services/codespace/inventory.go new file mode 100644 index 0000000000000..2c85e1414f3d6 --- /dev/null +++ b/services/codespace/inventory.go @@ -0,0 +1,317 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/modules/globallock" +) + +const maxRuntimeInstancesPerInventory = 10000 + +var ( + // ErrReportInstancesManagerUnavailable is returned when the Manager row is unavailable. + ErrReportInstancesManagerUnavailable = errors.New("manager is unavailable") + // ErrReportInstancesStateHistoryConflict is returned when Manager reports an unexplained newer operation version. + ErrReportInstancesStateHistoryConflict = errors.New("runtime inventory state history conflict") +) + +// ReportInstancesOptions contains one complete Manager inventory request. +type ReportInstancesOptions struct { + InventoryGeneration int64 + Instances []*codespacev1.RuntimeInstanceRef +} + +// ReportInstances accepts a complete Runtime inventory snapshot and returns reconciliation actions. +func ReportInstances(ctx context.Context, manager *codespace_model.Manager, opts ReportInstancesOptions) (*codespacev1.ReportInstancesResponse, error) { + if manager == nil || manager.ID <= 0 { + return nil, errors.New("manager is required") + } + if err := validateReportInstancesOptions(opts); err != nil { + return nil, err + } + if err := precheckInventoryObservedVersions(ctx, manager.ID, opts.Instances); err != nil { + return nil, err + } + if err := acceptInventoryGeneration(ctx, manager.ID, opts.InventoryGeneration); err != nil { + return nil, err + } + + reported := make(map[string]*codespacev1.RuntimeInstanceRef, len(opts.Instances)) + response := &codespacev1.ReportInstancesResponse{Results: make([]*codespacev1.RuntimeInstanceResult, 0, len(opts.Instances))} + for _, instance := range opts.Instances { + reported[instance.GetCodespaceUuid()] = instance + if err := ensureInventoryGenerationCurrent(ctx, manager.ID, opts.InventoryGeneration); err != nil { + return nil, err + } + result, err := processReportedRuntimeInstance(ctx, manager.ID, instance) + if err != nil { + return nil, err + } + response.Results = append(response.Results, result) + } + if err := processMissingRuntimeInstances(ctx, manager.ID, opts.InventoryGeneration, reported); err != nil { + return nil, err + } + if err := ensureInventoryGenerationCurrent(ctx, manager.ID, opts.InventoryGeneration); err != nil { + return nil, err + } + return response, nil +} + +func validateReportInstancesOptions(opts ReportInstancesOptions) error { + if opts.InventoryGeneration <= 0 { + return errors.New("inventory_generation must be positive") + } + if len(opts.Instances) > maxRuntimeInstancesPerInventory { + return fmt.Errorf("instances exceeds maximum %d", maxRuntimeInstancesPerInventory) + } + seen := make(map[string]struct{}, len(opts.Instances)) + for _, instance := range opts.Instances { + if instance == nil { + return errors.New("runtime instance is required") + } + if err := codespace_model.ValidateUUID(instance.GetCodespaceUuid()); err != nil { + return err + } + if _, ok := seen[instance.GetCodespaceUuid()]; ok { + return fmt.Errorf("duplicate codespace_uuid %q", instance.GetCodespaceUuid()) + } + seen[instance.GetCodespaceUuid()] = struct{}{} + if !validRuntimeInstanceState(instance.GetRuntimeState()) { + return fmt.Errorf("invalid runtime_state %d", instance.GetRuntimeState()) + } + if instance.GetObservedOperationRversion() < 0 { + return errors.New("observed_operation_rversion must not be negative") + } + } + return nil +} + +func validRuntimeInstanceState(state codespacev1.RuntimeState) bool { + switch state { + case codespacev1.RuntimeState_RUNTIME_STATE_CREATING, + codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, + codespacev1.RuntimeState_RUNTIME_STATE_STOPPED, + codespacev1.RuntimeState_RUNTIME_STATE_FAILED: + return true + default: + return false + } +} + +func precheckInventoryObservedVersions(ctx context.Context, managerID int64, instances []*codespacev1.RuntimeInstanceRef) error { + for _, instance := range instances { + if instance.GetObservedOperationRversion() <= 0 { + continue + } + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(instance.GetCodespaceUuid()).Get(codespace) + if err != nil { + return err + } + if has && codespace.ManagerID == managerID && instance.GetObservedOperationRversion() > codespace.OperationRVersion { + return ErrReportInstancesStateHistoryConflict + } + } + return nil +} + +func acceptInventoryGeneration(ctx context.Context, managerID, inventoryGeneration int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + affected, err := db.GetEngine(ctx). + Where("id = ? AND inventory_generation < ?", managerID, inventoryGeneration). + Cols("inventory_generation"). + Update(&codespace_model.Manager{InventoryGeneration: inventoryGeneration}) + if err != nil { + return err + } + if affected == 1 { + return nil + } + + manager := new(codespace_model.Manager) + has, err := db.GetEngine(ctx).ID(managerID).Get(manager) + if err != nil { + return err + } + if !has { + return ErrReportInstancesManagerUnavailable + } + if manager.InventoryGeneration == inventoryGeneration { + return nil + } + return &StaleGenerationError{CurrentGeneration: manager.InventoryGeneration} + }) +} + +func ensureInventoryGenerationCurrent(ctx context.Context, managerID, inventoryGeneration int64) error { + manager := new(codespace_model.Manager) + has, err := db.GetEngine(ctx).ID(managerID).Get(manager) + if err != nil { + return err + } + if !has { + return ErrReportInstancesManagerUnavailable + } + if manager.InventoryGeneration != inventoryGeneration { + return &StaleGenerationError{CurrentGeneration: manager.InventoryGeneration} + } + return nil +} + +func processReportedRuntimeInstance(ctx context.Context, managerID int64, instance *codespacev1.RuntimeInstanceRef) (*codespacev1.RuntimeInstanceResult, error) { + var result *codespacev1.RuntimeInstanceResult + err := globallock.LockAndDo(ctx, inventoryCodespaceLockKey(instance.GetCodespaceUuid()), func(ctx context.Context) error { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(instance.GetCodespaceUuid()).Get(codespace) + if err != nil { + return err + } + result = runtimeInstanceResult(managerID, codespace, has, instance) + return nil + }) + return result, err +} + +func runtimeInstanceResult(managerID int64, codespace *codespace_model.Codespace, has bool, instance *codespacev1.RuntimeInstanceRef) *codespacev1.RuntimeInstanceResult { + result := &codespacev1.RuntimeInstanceResult{CodespaceUuid: instance.GetCodespaceUuid()} + if !has { + result.Action = codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_CLEANUP_LOCAL_RUNTIME + return result + } + if codespace.ManagerID != managerID { + if codespace.ManagerID == 0 && codespace.Status == codespace_model.StatusCreating { + return result + } + result.Action = codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_CLEANUP_LOCAL_RUNTIME + return result + } + if codespace.Status == codespace_model.StatusFailed { + result.Action = codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_CLEANUP_LOCAL_RUNTIME + return result + } + + result.RuntimeSettings = runtimeSettingsMessage(effectiveRuntimeSettings(codespace)) + if hasActiveOperation(codespace) { + switch { + case instance.GetObservedOperationRversion() > 0 && instance.GetObservedOperationRversion() < codespace.OperationRVersion: + result.Action = codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_REFETCH_OPERATION + result.CurrentOperationRversion = codespace.OperationRVersion + } + return result + } + if instance.GetObservedOperationRversion() > 0 { + result.Action = codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_CLEAR_OPERATION_CONTEXT + result.CurrentOperationRversion = codespace.OperationRVersion + return result + } + switch { + case codespace.Status == codespace_model.StatusRunning && instance.GetRuntimeState() == codespacev1.RuntimeState_RUNTIME_STATE_STOPPED: + result.Action = codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_REPORT_RUNTIME_TRANSITION + result.CurrentOperationRversion = codespace.OperationRVersion + case (codespace.Status == codespace_model.StatusRunning || codespace.Status == codespace_model.StatusStopped) && instance.GetRuntimeState() == codespacev1.RuntimeState_RUNTIME_STATE_FAILED: + result.Action = codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_REPORT_RUNTIME_TRANSITION + result.CurrentOperationRversion = codespace.OperationRVersion + case codespace.Status == codespace_model.StatusStopped && instance.GetRuntimeState() == codespacev1.RuntimeState_RUNTIME_STATE_RUNNING: + result.Action = codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_STOP_LOCAL_RUNTIME + result.CurrentOperationRversion = codespace.OperationRVersion + } + return result +} + +func processMissingRuntimeInstances(ctx context.Context, managerID, inventoryGeneration int64, reported map[string]*codespacev1.RuntimeInstanceRef) error { + var expected []*codespace_model.Codespace + if err := db.GetEngine(ctx). + Where("manager_id = ?", managerID). + In("status", codespace_model.StatusCreating, codespace_model.StatusRunning, codespace_model.StatusStopped, codespace_model.StatusDeleting). + Find(&expected); err != nil { + return err + } + now := time.Now().Unix() + for _, codespace := range expected { + if _, ok := reported[codespace.UUID]; ok { + continue + } + if err := ensureInventoryGenerationCurrent(ctx, managerID, inventoryGeneration); err != nil { + return err + } + if err := processMissingRuntimeInstance(ctx, managerID, inventoryGeneration, codespace.UUID, now); err != nil { + return err + } + } + return nil +} + +func processMissingRuntimeInstance(ctx context.Context, managerID, inventoryGeneration int64, codespaceUUID string, now int64) error { + var summary *internalStateSummary + err := globallock.LockAndDo(ctx, inventoryCodespaceLockKey(codespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + if err := ensureInventoryGenerationCurrent(ctx, managerID, inventoryGeneration); err != nil { + return err + } + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(codespaceUUID).Get(codespace) + if err != nil || !has { + return err + } + if codespace.ManagerID != managerID { + return nil + } + switch codespace.Status { + case codespace_model.StatusCreating: + if currentOperationMatches(codespace, codespace_model.OperationCreate, codespace.OperationRVersion) && + codespace.OperationDeadlineUnix > now { + return nil + } + summary = runtimeMissingSummary(codespace) + return applyInventoryMissingFailed(ctx, codespace, now) + case codespace_model.StatusRunning, codespace_model.StatusStopped: + summary = runtimeMissingSummary(codespace) + return applyInventoryMissingFailed(ctx, codespace, now) + case codespace_model.StatusDeleting: + return deleteCodespaceForFinal(ctx, codespace.UUID) + default: + return nil + } + }) + }) + if err != nil { + return err + } + appendInternalStateSummary(ctx, summary) + return nil +} + +func applyInventoryMissingFailed(ctx context.Context, codespace *codespace_model.Codespace, now int64) error { + codespace.Status = codespace_model.StatusFailed + codespace.UpdatedUnix = now + clearActiveOperation(codespace) + if err := cleanupCredentialsForStatus(ctx, codespace.UUID, codespace_model.StatusFailed); err != nil { + return err + } + deleteRuntimeMetadata(codespace.UUID) + _, err := db.GetEngine(ctx).ID(codespace.UUID).Cols( + "status", + "operation_type", + "operation_status", + "operation_trigger", + "operation_created_unix", + "operation_started_unix", + "operation_deadline_unix", + "updated_unix", + ).Update(codespace) + return err +} + +func inventoryCodespaceLockKey(codespaceUUID string) string { + return "codespace_inventory_" + codespaceUUID +} diff --git a/services/codespace/inventory_test.go b/services/codespace/inventory_test.go new file mode 100644 index 0000000000000..6927a06fb19c8 --- /dev/null +++ b/services/codespace/inventory_test.go @@ -0,0 +1,313 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReportInstancesReturnsSettingsAndActions(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + otherManager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + markServiceManagerOnline(t, otherManager, `[{"tag":"default"}]`) + runningUUID := "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: runningUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 81, + InteractionGeneration: 6, + }) + activeUUID := "a2a2a2a2-a2a2-4a2a-8a2a-a2a2a2a2a2a2" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: activeUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 82, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + }) + stoppedUUID := "a3a3a3a3-a3a3-4a3a-8a3a-a3a3a3a3a3a3" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: stoppedUUID, + Status: codespace_model.StatusStopped, + OperationRVersion: 83, + }) + failedUUID := "a4a4a4a4-a4a4-4a4a-8a4a-a4a4a4a4a4a4" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: failedUUID, + Status: codespace_model.StatusFailed, + OperationRVersion: 84, + }) + otherUUID := "a5a5a5a5-a5a5-4a5a-8a5a-a5a5a5a5a5a5" + insertServiceCodespace(t, otherManager.ID, &codespace_model.Codespace{ + UUID: otherUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 85, + }) + unboundUUID := "a6a6a6a6-a6a6-4a6a-8a6a-a6a6a6a6a6a6" + insertServiceCodespace(t, 0, &codespace_model.Codespace{ + UUID: unboundUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 86, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + }) + absentUUID := "a7a7a7a7-a7a7-4a7a-8a7a-a7a7a7a7a7a7" + + _, err := ReportInstances(t.Context(), manager, ReportInstancesOptions{ + InventoryGeneration: 1, + Instances: []*codespacev1.RuntimeInstanceRef{ + {CodespaceUuid: runningUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, + {CodespaceUuid: activeUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 81}, + {CodespaceUuid: runningUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED}, + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate") + + result, err := ReportInstances(t.Context(), manager, ReportInstancesOptions{ + InventoryGeneration: 1, + Instances: []*codespacev1.RuntimeInstanceRef{ + {CodespaceUuid: runningUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, + {CodespaceUuid: activeUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 81}, + {CodespaceUuid: stoppedUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, + {CodespaceUuid: failedUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, + {CodespaceUuid: otherUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, + {CodespaceUuid: unboundUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_CREATING}, + {CodespaceUuid: absentUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, + }, + }) + require.NoError(t, err) + require.Len(t, result.Results, 7) + assert.NotNil(t, result.Results[0].RuntimeSettings) + assert.Equal(t, codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_UNSPECIFIED, result.Results[0].GetAction()) + assert.Equal(t, codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_REFETCH_OPERATION, result.Results[1].GetAction()) + assert.EqualValues(t, 82, result.Results[1].GetCurrentOperationRversion()) + assert.Equal(t, codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_STOP_LOCAL_RUNTIME, result.Results[2].GetAction()) + assert.EqualValues(t, 83, result.Results[2].GetCurrentOperationRversion()) + assert.Equal(t, codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_CLEANUP_LOCAL_RUNTIME, result.Results[3].GetAction()) + assert.Nil(t, result.Results[3].RuntimeSettings) + assert.Equal(t, codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_CLEANUP_LOCAL_RUNTIME, result.Results[4].GetAction()) + assert.Equal(t, codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_UNSPECIFIED, result.Results[5].GetAction()) + assert.Nil(t, result.Results[5].RuntimeSettings) + assert.Equal(t, codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_CLEANUP_LOCAL_RUNTIME, result.Results[6].GetAction()) + assert.EqualValues(t, 1, loadServiceManager(t, manager.ID).InventoryGeneration) +} + +func TestReportInstancesReturnsDisabledRuntimeSettings(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + defer test.MockVariableValue(&setting.Codespace.Enabled, false)() + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "a8a8a8a8-a8a8-4a8a-8a8a-a8a8a8a8a8a8" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 87, + InteractionGeneration: 9, + }) + + result, err := ReportInstances(t.Context(), manager, ReportInstancesOptions{ + InventoryGeneration: 11, + Instances: []*codespacev1.RuntimeInstanceRef{{ + CodespaceUuid: codespaceUUID, + RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, + }}, + }) + require.NoError(t, err) + require.Len(t, result.Results, 1) + require.NotNil(t, result.Results[0].RuntimeSettings) + assert.False(t, result.Results[0].RuntimeSettings.GetAutoStopEnabled()) + assert.Zero(t, result.Results[0].RuntimeSettings.GetIdleTimeoutSeconds()) + assert.EqualValues(t, 9, result.Results[0].RuntimeSettings.GetInteractionGeneration()) + assert.Equal(t, codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_UNSPECIFIED, result.Results[0].GetAction()) +} + +func TestReportInstancesTransitionAndClearActions(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + stoppedRuntimeUUID := "b1b1b1b1-b1b1-4b1b-8b1b-b1b1b1b1b1b1" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: stoppedRuntimeUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 91, + }) + failedRuntimeUUID := "b2b2b2b2-b2b2-4b2b-8b2b-b2b2b2b2b2b2" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: failedRuntimeUUID, + Status: codespace_model.StatusStopped, + OperationRVersion: 92, + }) + clearUUID := "b3b3b3b3-b3b3-4b3b-8b3b-b3b3b3b3b3b3" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: clearUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 93, + }) + + result, err := ReportInstances(t.Context(), manager, ReportInstancesOptions{ + InventoryGeneration: 2, + Instances: []*codespacev1.RuntimeInstanceRef{ + {CodespaceUuid: stoppedRuntimeUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED}, + {CodespaceUuid: failedRuntimeUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, + {CodespaceUuid: clearUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 93}, + }, + }) + require.NoError(t, err) + require.Len(t, result.Results, 3) + assert.Equal(t, codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_REPORT_RUNTIME_TRANSITION, result.Results[0].GetAction()) + assert.EqualValues(t, 91, result.Results[0].GetCurrentOperationRversion()) + assert.Equal(t, codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_REPORT_RUNTIME_TRANSITION, result.Results[1].GetAction()) + assert.EqualValues(t, 92, result.Results[1].GetCurrentOperationRversion()) + assert.Equal(t, codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_CLEAR_OPERATION_CONTEXT, result.Results[2].GetAction()) + assert.EqualValues(t, 93, result.Results[2].GetCurrentOperationRversion()) +} + +func TestReportInstancesGenerationAndMissingState(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + missingRunningUUID := "c1c1c1c1-c1c1-4c1c-8c1c-c1c1c1c1c1c1" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: missingRunningUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 101, + }) + missingCreateUUID := "c2c2c2c2-c2c2-4c2c-8c2c-c2c2c2c2c2c2" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: missingCreateUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 102, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + + result, err := ReportInstances(t.Context(), manager, ReportInstancesOptions{ + InventoryGeneration: 3, + }) + require.NoError(t, err) + assert.Empty(t, result.Results) + missingRunning := loadServiceCodespace(t, missingRunningUUID) + assert.Equal(t, codespace_model.StatusFailed, missingRunning.Status) + assert.Contains(t, readServiceLog(t, codespaceLogFilename(missingRunning.UUID)), "Gitea recorded missing runtime as failed.") + assert.Equal(t, codespace_model.StatusCreating, loadServiceCodespace(t, missingCreateUUID).Status) + + result, err = ReportInstances(t.Context(), manager, ReportInstancesOptions{ + InventoryGeneration: 3, + }) + require.NoError(t, err) + assert.Empty(t, result.Results) + + _, err = ReportInstances(t.Context(), manager, ReportInstancesOptions{ + InventoryGeneration: 2, + }) + var stale *StaleGenerationError + require.ErrorAs(t, err, &stale) + assert.EqualValues(t, 3, stale.CurrentGeneration) +} + +func TestAcceptInventoryGenerationDoesNotRegress(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + require.NoError(t, acceptInventoryGeneration(t.Context(), manager.ID, 9)) + err := acceptInventoryGeneration(t.Context(), manager.ID, 8) + var stale *StaleGenerationError + require.ErrorAs(t, err, &stale) + assert.EqualValues(t, 9, stale.CurrentGeneration) + assert.EqualValues(t, 9, loadServiceManager(t, manager.ID).InventoryGeneration) +} + +func TestProcessMissingRuntimeInstanceRechecksGenerationAndBinding(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + otherManager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + markServiceManagerOnline(t, otherManager, `[{"tag":"default"}]`) + staleUUID := "c3c3c3c3-c3c3-4c3c-8c3c-c3c3c3c3c3c3" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: staleUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 103, + }) + currentManager := loadServiceManager(t, manager.ID) + currentManager.InventoryGeneration = 9 + _, err := db.GetEngine(t.Context()).ID(manager.ID).Cols("inventory_generation").Update(currentManager) + require.NoError(t, err) + + err = processMissingRuntimeInstance(t.Context(), manager.ID, 8, staleUUID, time.Now().Unix()) + var stale *StaleGenerationError + require.ErrorAs(t, err, &stale) + assert.EqualValues(t, 9, stale.CurrentGeneration) + assert.Equal(t, codespace_model.StatusRunning, loadServiceCodespace(t, staleUUID).Status) + + movedUUID := "c4c4c4c4-c4c4-4c4c-8c4c-c4c4c4c4c4c4" + insertServiceCodespace(t, otherManager.ID, &codespace_model.Codespace{ + UUID: movedUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 104, + }) + + require.NoError(t, processMissingRuntimeInstance(t.Context(), manager.ID, 9, movedUUID, time.Now().Unix())) + assert.Equal(t, codespace_model.StatusRunning, loadServiceCodespace(t, movedUUID).Status) + assert.Equal(t, otherManager.ID, loadServiceCodespace(t, movedUUID).ManagerID) +} + +func TestReportInstancesRejectsStateHistoryConflict(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "d1d1d1d1-d1d1-4d1d-8d1d-d1d1d1d1d1d1" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 111, + }) + + _, err := ReportInstances(t.Context(), manager, ReportInstancesOptions{ + InventoryGeneration: 4, + Instances: []*codespacev1.RuntimeInstanceRef{{ + CodespaceUuid: codespaceUUID, + RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, + ObservedOperationRversion: 112, + }}, + }) + require.ErrorIs(t, err, ErrReportInstancesStateHistoryConflict) + assert.EqualValues(t, 0, loadServiceManager(t, manager.ID).InventoryGeneration) +} + +func loadServiceManager(t *testing.T, managerID int64) *codespace_model.Manager { + t.Helper() + manager := new(codespace_model.Manager) + has, err := db.GetEngine(t.Context()).ID(managerID).Get(manager) + require.NoError(t, err) + require.True(t, has) + return manager +} diff --git a/services/codespace/lifecycle.go b/services/codespace/lifecycle.go new file mode 100644 index 0000000000000..5f9413fb03522 --- /dev/null +++ b/services/codespace/lifecycle.go @@ -0,0 +1,230 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" +) + +// ErrFinalizeMetadataRequired is returned until current-version ready metadata is available. +var ErrFinalizeMetadataRequired = errors.New("current ready runtime metadata is required") + +// ErrFinalizeGiteaTokenRequired is returned when final done lacks a Codespace token. +var ErrFinalizeGiteaTokenRequired = errors.New("codespace gitea token is required") + +// FinalizeOperationOptions contains a Manager final report. +type FinalizeOperationOptions struct { + CodespaceUUID string + OperationRVersion int64 + OperationType codespacev1.OperationType + FinalStatus codespacev1.FinalStatus +} + +// FinalizeOperation applies a Manager final report to the current active operation. +func FinalizeOperation(ctx context.Context, manager *codespace_model.Manager, opts FinalizeOperationOptions) (*codespacev1.FinalizeOperationResponse, error) { + if manager == nil || manager.ID <= 0 { + return nil, errors.New("manager is required") + } + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return nil, err + } + if opts.OperationRVersion <= 0 { + return nil, errors.New("operation_rversion must be positive") + } + operationType := finalOperationType(opts.OperationType) + if operationType == "" { + return nil, fmt.Errorf("invalid final operation type %d", opts.OperationType) + } + if opts.FinalStatus != codespacev1.FinalStatus_FINAL_STATUS_DONE && opts.FinalStatus != codespacev1.FinalStatus_FINAL_STATUS_FAILED { + return nil, fmt.Errorf("invalid final status %d", opts.FinalStatus) + } + response := &codespacev1.FinalizeOperationResponse{} + var stateSummary *internalStateSummary + err := db.WithTx(ctx, func(ctx context.Context) error { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceUUID).Get(codespace) + if err != nil { + return err + } + if !has { + response.ResourceAbsent = true + return nil + } + + if !isCurrentRunningOperation(codespace, manager.ID, opts.OperationRVersion) || codespace.OperationType != operationType { + return nil + } + now := time.Now().Unix() + if codespace.OperationDeadlineUnix > 0 && now >= codespace.OperationDeadlineUnix { + resultStatus := timeoutStatus(codespace.OperationType) + stateSummary = operationTimeoutSummary(codespace, resultStatus) + return applyFinalState(ctx, codespace, resultStatus, now) + } + if opts.FinalStatus == codespacev1.FinalStatus_FINAL_STATUS_DONE && + (opts.OperationType == codespacev1.OperationType_OPERATION_TYPE_CREATE || opts.OperationType == codespacev1.OperationType_OPERATION_TYPE_RESUME) { + if err := requireFinalizeReadyPrerequisites(ctx, opts.CodespaceUUID, opts.OperationRVersion); err != nil { + return err + } + } + return applyFinalOperation(ctx, codespace, opts, now) + }) + if err != nil { + return nil, err + } + appendInternalStateSummary(ctx, stateSummary) + return response, nil +} + +func isCurrentRunningOperation(codespace *codespace_model.Codespace, managerID, operationRVersion int64) bool { + return codespace.ManagerID == managerID && + codespace.OperationRVersion == operationRVersion && + codespace.OperationStatus == codespace_model.OperationStatusRunning +} + +func hasActiveOperation(codespace *codespace_model.Codespace) bool { + return codespace.OperationType != "" || codespace.OperationStatus != "" || codespace.OperationTrigger != "" +} + +func requireFinalizeReadyPrerequisites(ctx context.Context, codespaceUUID string, operationRVersion int64) error { + hasToken, err := hasValidCurrentGiteaToken(ctx, codespaceUUID) + if err != nil { + return err + } + if !hasToken { + return ErrFinalizeGiteaTokenRequired + } + hasMetadata, err := HasReadyRuntimeMetadata(ctx, codespaceUUID, operationRVersion) + if err != nil { + return err + } + if !hasMetadata { + return ErrFinalizeMetadataRequired + } + return nil +} + +func applyFinalOperation(ctx context.Context, codespace *codespace_model.Codespace, opts FinalizeOperationOptions, now int64) error { + switch opts.FinalStatus { + case codespacev1.FinalStatus_FINAL_STATUS_DONE: + switch opts.OperationType { + case codespacev1.OperationType_OPERATION_TYPE_CREATE: + return applyFinalState(ctx, codespace, codespace_model.StatusRunning, now) + case codespacev1.OperationType_OPERATION_TYPE_RESUME: + codespace.LastActiveUnix = now + return applyFinalState(ctx, codespace, codespace_model.StatusRunning, now) + case codespacev1.OperationType_OPERATION_TYPE_STOP: + return applyFinalState(ctx, codespace, codespace_model.StatusStopped, now) + case codespacev1.OperationType_OPERATION_TYPE_DELETE: + return deleteCodespaceForFinal(ctx, codespace.UUID) + } + case codespacev1.FinalStatus_FINAL_STATUS_FAILED: + switch opts.OperationType { + case codespacev1.OperationType_OPERATION_TYPE_CREATE, codespacev1.OperationType_OPERATION_TYPE_STOP, codespacev1.OperationType_OPERATION_TYPE_DELETE: + return applyFinalState(ctx, codespace, codespace_model.StatusFailed, now) + case codespacev1.OperationType_OPERATION_TYPE_RESUME: + return applyFinalState(ctx, codespace, codespace_model.StatusStopped, now) + } + } + return errors.New("unsupported final result") +} + +func timeoutStatus(operationType string) string { + switch operationType { + case codespace_model.OperationResume: + return codespace_model.StatusStopped + default: + return codespace_model.StatusFailed + } +} + +func finalOperationType(operationType codespacev1.OperationType) string { + switch operationType { + case codespacev1.OperationType_OPERATION_TYPE_CREATE: + return codespace_model.OperationCreate + case codespacev1.OperationType_OPERATION_TYPE_RESUME: + return codespace_model.OperationResume + case codespacev1.OperationType_OPERATION_TYPE_STOP: + return codespace_model.OperationStop + case codespacev1.OperationType_OPERATION_TYPE_DELETE: + return codespace_model.OperationDelete + default: + return "" + } +} + +func applyFinalState(ctx context.Context, codespace *codespace_model.Codespace, status string, now int64) error { + codespace.Status = status + codespace.UpdatedUnix = now + clearActiveOperation(codespace) + if err := cleanupCredentialsForStatus(ctx, codespace.UUID, status); err != nil { + return err + } + if status != codespace_model.StatusRunning { + deleteRuntimeMetadata(codespace.UUID) + } + _, err := db.GetEngine(ctx).ID(codespace.UUID).Cols( + "status", + "operation_type", + "operation_status", + "operation_trigger", + "operation_created_unix", + "operation_started_unix", + "operation_deadline_unix", + "updated_unix", + "last_active_unix", + ).Update(codespace) + return err +} + +func clearActiveOperation(codespace *codespace_model.Codespace) { + codespace.OperationType = "" + codespace.OperationStatus = "" + codespace.OperationTrigger = "" + codespace.OperationCreatedUnix = 0 + codespace.OperationStartedUnix = 0 + codespace.OperationDeadlineUnix = 0 +} + +func cleanupCredentialsForStatus(ctx context.Context, codespaceUUID, status string) error { + switch status { + case codespace_model.StatusRunning: + return nil + case codespace_model.StatusStopped: + return deleteGiteaToken(ctx, codespaceUUID) + case codespace_model.StatusFailed, codespace_model.StatusDeleting: + if err := deleteGiteaToken(ctx, codespaceUUID); err != nil { + return err + } + return deleteGitSSHKey(ctx, codespaceUUID) + default: + return nil + } +} + +func deleteCodespaceForFinal(ctx context.Context, codespaceUUID string) error { + if err := deleteGiteaToken(ctx, codespaceUUID); err != nil { + return err + } + if err := deleteGitSSHKey(ctx, codespaceUUID); err != nil { + return err + } + if err := deleteCodespaceLog(ctx, codespaceUUID); err != nil { + return err + } + deleteRuntimeMetadata(codespaceUUID) + _, err := db.GetEngine(ctx).ID(codespaceUUID).Delete(new(codespace_model.Codespace)) + return err +} + +func deleteGiteaToken(ctx context.Context, codespaceUUID string) error { + _, err := db.GetEngine(ctx).Where("codespace_uuid = ?", codespaceUUID).Delete(new(codespace_model.GiteaToken)) + return err +} diff --git a/services/codespace/lifecycle_action.go b/services/codespace/lifecycle_action.go new file mode 100644 index 0000000000000..8a4e7d04c3900 --- /dev/null +++ b/services/codespace/lifecycle_action.go @@ -0,0 +1,244 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/modules/globallock" + "gitea.dev/modules/setting" +) + +var ( + // ErrLifecycleActionNotFound is returned when the Codespace no longer exists. + ErrLifecycleActionNotFound = errors.New("codespace not found") + // ErrLifecycleActionPermissionDenied is returned when the user is not the Codespace creator. + ErrLifecycleActionPermissionDenied = errors.New("codespace permission denied") + // ErrLifecycleActionStateUnavailable is returned when the lifecycle state cannot accept the action. + ErrLifecycleActionStateUnavailable = errors.New("codespace lifecycle state unavailable") + // ErrLifecycleActionVersionExhausted is returned when operation_rversion cannot advance. + ErrLifecycleActionVersionExhausted = errors.New("codespace operation version exhausted") +) + +// LifecycleActionOptions identifies one creator lifecycle request. +type LifecycleActionOptions struct { + UserID int64 + CodespaceUUID string +} + +// LifecycleActionResult contains the accepted operation state. +type LifecycleActionResult struct { + Status string + OperationType string + OperationRVersion int64 + Deleted bool +} + +// StopCodespace queues a user stop operation for a running Codespace. +func StopCodespace(ctx context.Context, opts LifecycleActionOptions) (*LifecycleActionResult, error) { + return applyCreatorLifecycleAction(ctx, opts, codespace_model.OperationStop) +} + +// ResumeCodespace queues a user resume operation for a stopped Codespace. +func ResumeCodespace(ctx context.Context, opts LifecycleActionOptions) (*LifecycleActionResult, error) { + if !setting.Codespace.Enabled { + return nil, ErrLifecycleActionStateUnavailable + } + return applyCreatorLifecycleAction(ctx, opts, codespace_model.OperationResume) +} + +// DeleteCodespace deletes an unbound Codespace or queues a bound delete operation. +func DeleteCodespace(ctx context.Context, opts LifecycleActionOptions) (*LifecycleActionResult, error) { + return applyCreatorLifecycleAction(ctx, opts, codespace_model.OperationDelete) +} + +func applyCreatorLifecycleAction(ctx context.Context, opts LifecycleActionOptions, operationType string) (*LifecycleActionResult, error) { + if err := validateLifecycleActionOptions(opts); err != nil { + return nil, err + } + + var result *LifecycleActionResult + err := globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace, err := loadLifecycleActionCodespace(ctx, opts) + if err != nil { + return err + } + now := time.Now().Unix() + switch operationType { + case codespace_model.OperationStop: + result, err = applyStopAction(ctx, codespace, now) + case codespace_model.OperationResume: + result, err = applyResumeAction(ctx, codespace, now) + case codespace_model.OperationDelete: + result, err = applyDeleteAction(ctx, codespace, now) + default: + err = fmt.Errorf("unsupported lifecycle operation %q", operationType) + } + return err + }) + }) + if err != nil { + return nil, err + } + return result, nil +} + +func applyStopAction(ctx context.Context, codespace *codespace_model.Codespace, now int64) (*LifecycleActionResult, error) { + if codespace.Status != codespace_model.StatusRunning { + return nil, ErrLifecycleActionStateUnavailable + } + if hasActiveOperation(codespace) && !isQueuedIdleStop(codespace) { + return nil, ErrLifecycleActionStateUnavailable + } + if isQueuedIdleStop(codespace) { + codespace.OperationTrigger = codespace_model.OperationTriggerUser + if _, err := db.GetEngine(ctx).ID(codespace.UUID).Cols("operation_trigger").Update(codespace); err != nil { + return nil, err + } + return lifecycleActionResult(codespace), nil + } + if err := queueLifecycleOperation(ctx, codespace, codespace_model.OperationStop, codespace_model.StatusRunning, now, false); err != nil { + return nil, err + } + return lifecycleActionResult(codespace), nil +} + +func applyResumeAction(ctx context.Context, codespace *codespace_model.Codespace, now int64) (*LifecycleActionResult, error) { + if codespace.Status != codespace_model.StatusStopped || hasActiveOperation(codespace) || codespace.ManagerID <= 0 { + return nil, ErrLifecycleActionStateUnavailable + } + if err := queueLifecycleOperation(ctx, codespace, codespace_model.OperationResume, codespace_model.StatusStopped, now, true); err != nil { + return nil, err + } + return lifecycleActionResult(codespace), nil +} + +func applyDeleteAction(ctx context.Context, codespace *codespace_model.Codespace, now int64) (*LifecycleActionResult, error) { + if codespace.ManagerID <= 0 { + if codespace.Status != codespace_model.StatusCreating && codespace.Status != codespace_model.StatusFailed { + return nil, ErrLifecycleActionStateUnavailable + } + deleted, err := deleteUnboundCodespaceIfCurrent(ctx, codespace) + if err != nil { + return nil, err + } + if !deleted { + return nil, ErrLifecycleActionStateUnavailable + } + return &LifecycleActionResult{Deleted: true}, nil + } + if codespace.Status == codespace_model.StatusDeleting && codespace.OperationType == codespace_model.OperationDelete { + return lifecycleActionResult(codespace), nil + } + if err := cleanupCredentialsForStatus(ctx, codespace.UUID, codespace_model.StatusDeleting); err != nil { + return nil, err + } + if err := queueLifecycleOperation(ctx, codespace, codespace_model.OperationDelete, codespace_model.StatusDeleting, now, false); err != nil { + return nil, err + } + deleteRuntimeMetadata(codespace.UUID) + return lifecycleActionResult(codespace), nil +} + +func queueLifecycleOperation(ctx context.Context, codespace *codespace_model.Codespace, operationType, status string, now int64, advanceInteraction bool) error { + nextVersion, err := codespace_model.NextVersion(codespace.OperationRVersion) + if err != nil { + return ErrLifecycleActionVersionExhausted + } + cols := []string{ + "status", + "operation_r_version", + "operation_type", + "operation_status", + "operation_trigger", + "operation_created_unix", + "operation_started_unix", + "operation_deadline_unix", + "updated_unix", + } + if advanceInteraction { + nextInteractionGeneration, err := codespace_model.NextVersion(codespace.InteractionGeneration) + if err != nil { + return ErrLifecycleActionVersionExhausted + } + codespace.InteractionGeneration = nextInteractionGeneration + codespace.LastActiveUnix = now + cols = append(cols, "interaction_generation", "last_active_unix") + } + codespace.Status = status + codespace.OperationRVersion = nextVersion + codespace.OperationType = operationType + codespace.OperationStatus = codespace_model.OperationStatusQueued + codespace.OperationTrigger = codespace_model.OperationTriggerUser + codespace.OperationCreatedUnix = now + codespace.OperationStartedUnix = 0 + codespace.OperationDeadlineUnix = 0 + codespace.UpdatedUnix = now + _, err = db.GetEngine(ctx).ID(codespace.UUID).Cols(cols...).Update(codespace) + return err +} + +func deleteUnboundCodespaceIfCurrent(ctx context.Context, codespace *codespace_model.Codespace) (bool, error) { + affected, err := db.GetEngine(ctx). + Where("uuid = ? AND user_id = ? AND manager_id = 0 AND status = ? AND operation_r_version = ? AND operation_type = ? AND operation_status = ? AND operation_trigger = ?", + codespace.UUID, + codespace.UserID, + codespace.Status, + codespace.OperationRVersion, + codespace.OperationType, + codespace.OperationStatus, + codespace.OperationTrigger, + ). + Delete(new(codespace_model.Codespace)) + if err != nil || affected == 0 { + return affected > 0, err + } + if err := deleteGiteaToken(ctx, codespace.UUID); err != nil { + return false, err + } + if err := deleteGitSSHKey(ctx, codespace.UUID); err != nil { + return false, err + } + if err := deleteCodespaceLog(ctx, codespace.UUID); err != nil { + return false, err + } + deleteRuntimeMetadata(codespace.UUID) + return true, nil +} + +func validateLifecycleActionOptions(opts LifecycleActionOptions) error { + if opts.UserID <= 0 { + return errors.New("user_id must be positive") + } + return codespace_model.ValidateUUID(opts.CodespaceUUID) +} + +func loadLifecycleActionCodespace(ctx context.Context, opts LifecycleActionOptions) (*codespace_model.Codespace, error) { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceUUID).Get(codespace) + if err != nil { + return nil, err + } + if !has { + return nil, ErrLifecycleActionNotFound + } + if codespace.UserID != opts.UserID { + return nil, ErrLifecycleActionPermissionDenied + } + return codespace, nil +} + +func lifecycleActionResult(codespace *codespace_model.Codespace) *LifecycleActionResult { + return &LifecycleActionResult{ + Status: codespace.Status, + OperationType: codespace.OperationType, + OperationRVersion: codespace.OperationRVersion, + } +} diff --git a/services/codespace/lifecycle_action_test.go b/services/codespace/lifecycle_action_test.go new file mode 100644 index 0000000000000..7f41ca70a692f --- /dev/null +++ b/services/codespace/lifecycle_action_test.go @@ -0,0 +1,261 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "math" + "testing" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStopCodespaceQueuesUserStopAndTakesQueuedIdleStop(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "67676767-6767-4676-8676-676767676767" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 15, + }) + result, err := StopCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: codespaceUUID}) + require.NoError(t, err) + assert.Equal(t, codespace_model.StatusRunning, result.Status) + assert.Equal(t, codespace_model.OperationStop, result.OperationType) + assert.EqualValues(t, 16, result.OperationRVersion) + row := loadServiceCodespace(t, codespaceUUID) + assert.Equal(t, codespace_model.OperationStatusQueued, row.OperationStatus) + assert.Equal(t, codespace_model.OperationTriggerUser, row.OperationTrigger) + + idleUUID := "68686868-6868-4686-8686-686868686868" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: idleUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 17, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerIdle, + OperationCreatedUnix: time.Now().Unix(), + }) + updatedUnix := loadServiceCodespace(t, idleUUID).UpdatedUnix + result, err = StopCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: idleUUID}) + require.NoError(t, err) + assert.EqualValues(t, 17, result.OperationRVersion) + row = loadServiceCodespace(t, idleUUID) + assert.Equal(t, codespace_model.OperationTriggerUser, row.OperationTrigger) + assert.Equal(t, updatedUnix, row.UpdatedUnix) +} + +func TestStopCodespaceRejectsActiveUserStop(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + queuedUUID := "68686868-6868-4686-8686-686868686869" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: queuedUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 17, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + }) + _, err := StopCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: queuedUUID}) + require.ErrorIs(t, err, ErrLifecycleActionStateUnavailable) + + runningUUID := "68686868-6868-4686-8686-686868686870" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: runningUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 18, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + OperationStartedUnix: time.Now().Unix(), + OperationDeadlineUnix: time.Now().Add(time.Minute).Unix(), + }) + _, err = StopCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: runningUUID}) + require.ErrorIs(t, err, ErrLifecycleActionStateUnavailable) +} + +func TestResumeCodespaceQueuesResume(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "69696969-6969-4696-8696-696969696969" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusStopped, + OperationRVersion: 18, + InteractionGeneration: 7, + }) + result, err := ResumeCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: codespaceUUID}) + require.NoError(t, err) + assert.Equal(t, codespace_model.StatusStopped, result.Status) + assert.Equal(t, codespace_model.OperationResume, result.OperationType) + assert.EqualValues(t, 19, result.OperationRVersion) + row := loadServiceCodespace(t, codespaceUUID) + assert.Equal(t, codespace_model.OperationStatusQueued, row.OperationStatus) + assert.Equal(t, codespace_model.OperationTriggerUser, row.OperationTrigger) + assert.EqualValues(t, 8, row.InteractionGeneration) + assert.Positive(t, row.LastActiveUnix) +} + +func TestDeleteCodespacePhysicalForUnboundCreatingAndFailed(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + creatingUUID := "70707070-7070-4707-8707-707070707070" + insertServiceCodespace(t, 0, &codespace_model.Codespace{ + UUID: creatingUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 20, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + }) + result, err := DeleteCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: creatingUUID}) + require.NoError(t, err) + assert.True(t, result.Deleted) + assertServiceNotExists(t, new(codespace_model.Codespace), "uuid = ?", creatingUUID) + + failedUUID := "71717171-7171-4717-8717-717171717171" + insertServiceCodespace(t, 0, &codespace_model.Codespace{ + UUID: failedUUID, + Status: codespace_model.StatusFailed, + }) + insertServiceCredentials(t, failedUUID) + result, err = DeleteCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: failedUUID}) + require.NoError(t, err) + assert.True(t, result.Deleted) + assertServiceNotExists(t, new(codespace_model.Codespace), "uuid = ?", failedUUID) + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", failedUUID) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", failedUUID) +} + +func TestDeleteUnboundCodespaceRequiresCurrentRow(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + codespaceUUID := "79797979-7979-4797-8797-797979797979" + insertServiceCodespace(t, 0, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusFailed, + OperationRVersion: 20, + }) + stale := loadServiceCodespace(t, codespaceUUID) + row := loadServiceCodespace(t, codespaceUUID) + row.OperationRVersion = 21 + _, err := unittest.GetXORMEngine().ID(codespaceUUID).Cols("operation_r_version").Update(row) + require.NoError(t, err) + + deleted, err := deleteUnboundCodespaceIfCurrent(t.Context(), stale) + require.NoError(t, err) + assert.False(t, deleted) + assertServiceExists(t, new(codespace_model.Codespace), "uuid = ?", codespaceUUID) +} + +func TestDeleteCodespaceQueuesBoundDeleteAndReplacesOperation(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + for _, tc := range []struct { + name string + uuid string + status string + operationType string + }{ + { + name: "create", + uuid: "72727272-7272-4727-8727-727272727271", + status: codespace_model.StatusCreating, + operationType: codespace_model.OperationCreate, + }, + { + name: "resume", + uuid: "72727272-7272-4727-8727-727272727272", + status: codespace_model.StatusStopped, + operationType: codespace_model.OperationResume, + }, + { + name: "stop", + uuid: "72727272-7272-4727-8727-727272727273", + status: codespace_model.StatusRunning, + operationType: codespace_model.OperationStop, + }, + } { + t.Run(tc.name, func(t *testing.T) { + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: tc.uuid, + Status: tc.status, + OperationRVersion: 21, + OperationType: tc.operationType, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + OperationStartedUnix: time.Now().Unix(), + OperationDeadlineUnix: time.Now().Add(time.Minute).Unix(), + }) + insertServiceCredentials(t, tc.uuid) + + result, err := DeleteCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: tc.uuid}) + require.NoError(t, err) + assert.False(t, result.Deleted) + assert.Equal(t, codespace_model.StatusDeleting, result.Status) + assert.Equal(t, codespace_model.OperationDelete, result.OperationType) + assert.EqualValues(t, 22, result.OperationRVersion) + row := loadServiceCodespace(t, tc.uuid) + assert.Equal(t, codespace_model.StatusDeleting, row.Status) + assert.Equal(t, codespace_model.OperationDelete, row.OperationType) + assert.Equal(t, codespace_model.OperationStatusQueued, row.OperationStatus) + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", tc.uuid) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", tc.uuid) + }) + } +} + +func TestLifecycleActionValidation(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + runningUUID := "73737373-7373-4737-8737-737373737373" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: runningUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: math.MaxInt64, + OperationType: codespace_model.OperationDelete, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + }) + _, err := ResumeCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: runningUUID}) + require.ErrorIs(t, err, ErrLifecycleActionStateUnavailable) + _, err = DeleteCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: runningUUID}) + require.ErrorIs(t, err, ErrLifecycleActionVersionExhausted) +} + +func TestLifecycleActionsRequireCreator(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "74747474-7474-4747-8747-747474747474" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 24, + }) + + _, err := StopCodespace(t.Context(), LifecycleActionOptions{UserID: 2, CodespaceUUID: codespaceUUID}) + require.ErrorIs(t, err, ErrLifecycleActionPermissionDenied) + row := loadServiceCodespace(t, codespaceUUID) + assert.Equal(t, codespace_model.StatusRunning, row.Status) + assert.Empty(t, row.OperationType) + assert.Empty(t, row.OperationStatus) +} diff --git a/services/codespace/lifecycle_test.go b/services/codespace/lifecycle_test.go new file mode 100644 index 0000000000000..c0dc13a15810c --- /dev/null +++ b/services/codespace/lifecycle_test.go @@ -0,0 +1,381 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "math" + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFinalizeOperationResumeFailedTransaction(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusStopped, + OperationRVersion: 3, + OperationType: codespace_model.OperationResume, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + insertServiceCredentials(t, codespaceUUID) + + outcome, err := FinalizeOperation(t.Context(), manager, FinalizeOperationOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 3, + OperationType: codespacev1.OperationType_OPERATION_TYPE_RESUME, + FinalStatus: codespacev1.FinalStatus_FINAL_STATUS_FAILED, + }) + require.NoError(t, err) + assert.False(t, outcome.GetResourceAbsent()) + + codespace := loadServiceCodespace(t, codespaceUUID) + assert.Equal(t, codespace_model.StatusStopped, codespace.Status) + assert.Empty(t, codespace.OperationType) + assert.Empty(t, codespace.OperationStatus) + assert.Positive(t, codespace.UpdatedUnix) + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", codespaceUUID) + assertServiceExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", codespaceUUID) +} + +func TestFinalizeOperationRejectsWrongManagerAsStale(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + otherManager := insertServiceManager(t) + codespaceUUID := "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 4, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + + _, err := FinalizeOperation(t.Context(), otherManager, FinalizeOperationOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 4, + OperationType: codespacev1.OperationType_OPERATION_TYPE_STOP, + FinalStatus: codespacev1.FinalStatus_FINAL_STATUS_DONE, + }) + require.NoError(t, err) + assert.Equal(t, codespace_model.OperationStatusRunning, loadServiceCodespace(t, codespaceUUID).OperationStatus) +} + +func TestFinalizeOperationStopTimeoutDoesNotMarkStopped(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbc" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 41, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(-time.Minute).Unix(), + }) + insertServiceCredentials(t, codespaceUUID) + + _, err := FinalizeOperation(t.Context(), manager, FinalizeOperationOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 41, + OperationType: codespacev1.OperationType_OPERATION_TYPE_STOP, + FinalStatus: codespacev1.FinalStatus_FINAL_STATUS_DONE, + }) + require.NoError(t, err) + codespace := loadServiceCodespace(t, codespaceUUID) + assert.Equal(t, codespace_model.StatusFailed, codespace.Status) + assert.Empty(t, codespace.OperationType) + assert.Empty(t, codespace.OperationStatus) + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", codespaceUUID) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", codespaceUUID) + assert.Contains(t, readServiceLog(t, codespaceLogFilename(codespace.UUID)), "Gitea recorded operation stop#41 timeout as failed.") +} + +func TestReportRuntimeMetadataRejectsStageRegression(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 5, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + + require.NoError(t, ReportRuntimeMetadata(t.Context(), manager, ReportRuntimeMetadataOptions{ + CodespaceUUID: codespaceUUID, + Metadata: serviceRuntimeMetadataProto(t, 5, "ready", nil), + MetadataGeneration: 1, + })) + + err := ReportRuntimeMetadata(t.Context(), manager, ReportRuntimeMetadataOptions{ + CodespaceUUID: codespaceUUID, + Metadata: serviceRuntimeMetadataProto(t, 5, "publish-ready", nil), + MetadataGeneration: 2, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRuntimeMetadataStaleOperation) + hasReady, err := HasReadyRuntimeMetadata(t.Context(), codespaceUUID, 5) + require.NoError(t, err) + assert.True(t, hasReady) +} + +func TestReportRuntimeMetadataRejectsGenerationExhaustion(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "cfcfcfcf-cfcf-4cfc-8cfc-cfcfcfcfcfcf" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 7, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + entry := serviceRuntimeMetadataEntry(t, 7, []map[string]any{}) + entry.Generation = math.MaxInt64 + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, entry)) + + err := ReportRuntimeMetadata(t.Context(), manager, ReportRuntimeMetadataOptions{ + CodespaceUUID: codespaceUUID, + Metadata: serviceRuntimeMetadataProto(t, 7, "ready", nil), + MetadataGeneration: math.MaxInt64 - 1, + }) + require.ErrorIs(t, err, ErrRuntimeMetadataVersionExhausted) +} + +func TestReportRuntimeMetadataUsesCurrentManagerAvailability(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + staleManager := *manager + codespaceUUID := "cececece-cece-4cec-8cec-cececececece" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 6, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + _, err := db.GetEngine(t.Context()). + ID(manager.ID). + Cols("last_online_unix"). + Update(&codespace_model.Manager{LastOnlineUnix: 1}) + require.NoError(t, err) + + err = ReportRuntimeMetadata(t.Context(), &staleManager, ReportRuntimeMetadataOptions{ + CodespaceUUID: codespaceUUID, + Metadata: serviceRuntimeMetadataProto(t, 6, "ready", nil), + MetadataGeneration: 1, + }) + require.ErrorIs(t, err, ErrRuntimeMetadataManagerOffline) + hasReady, err := HasReadyRuntimeMetadata(t.Context(), codespaceUUID, 6) + require.NoError(t, err) + assert.False(t, hasReady) + + _, err = db.GetEngine(t.Context()). + ID(manager.ID). + Cols("runtime_state", "last_online_unix"). + Update(&codespace_model.Manager{ + RuntimeState: codespace_model.ManagerRuntimeStateRecovering, + LastOnlineUnix: time.Now().Unix(), + }) + require.NoError(t, err) + err = ReportRuntimeMetadata(t.Context(), &staleManager, ReportRuntimeMetadataOptions{ + CodespaceUUID: codespaceUUID, + Metadata: serviceRuntimeMetadataProto(t, 6, "ready", nil), + MetadataGeneration: 1, + }) + require.NoError(t, err) +} + +func TestFinalizeOperationCreateDoneRejectsDamagedGiteaToken(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "11111111-1111-4111-8111-111111111111" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 6, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + require.NoError(t, ReportRuntimeMetadata(t.Context(), manager, ReportRuntimeMetadataOptions{ + CodespaceUUID: codespaceUUID, + Metadata: serviceRuntimeMetadataProto(t, 6, "ready", nil), + MetadataGeneration: 1, + })) + require.NoError(t, db.Insert(t.Context(), &codespace_model.GiteaToken{ + CodespaceUUID: codespaceUUID, + TokenHash: "hash-" + codespaceUUID, + TokenSalt: "salt-1", + TokenLastEight: "last0001", + TokenEncrypted: "encrypted", + })) + + outcome, err := FinalizeOperation(t.Context(), manager, FinalizeOperationOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 6, + OperationType: codespacev1.OperationType_OPERATION_TYPE_CREATE, + FinalStatus: codespacev1.FinalStatus_FINAL_STATUS_DONE, + }) + require.ErrorIs(t, err, ErrFinalizeGiteaTokenRequired) + assert.Empty(t, outcome) + assert.Equal(t, codespace_model.OperationStatusRunning, loadServiceCodespace(t, codespaceUUID).OperationStatus) +} + +func TestFinalizeStopClearsRuntimeMetadata(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "dddddddd-dddd-4ddd-8ddd-dddddddddddd" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 6, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + insertServiceCredentials(t, codespaceUUID) + + require.NoError(t, ReportRuntimeMetadata(t.Context(), manager, ReportRuntimeMetadataOptions{ + CodespaceUUID: codespaceUUID, + Metadata: serviceRuntimeMetadataProto(t, 6, "ready", nil), + MetadataGeneration: 1, + })) + hasReady, err := HasReadyRuntimeMetadata(t.Context(), codespaceUUID, 6) + require.NoError(t, err) + require.True(t, hasReady) + + outcome, err := FinalizeOperation(t.Context(), manager, FinalizeOperationOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 6, + OperationType: codespacev1.OperationType_OPERATION_TYPE_STOP, + FinalStatus: codespacev1.FinalStatus_FINAL_STATUS_DONE, + }) + require.NoError(t, err) + assert.False(t, outcome.GetResourceAbsent()) + hasReady, err = HasReadyRuntimeMetadata(t.Context(), codespaceUUID, 6) + require.NoError(t, err) + assert.False(t, hasReady) +} + +func insertServiceManager(t *testing.T) *codespace_model.Manager { + t.Helper() + manager := &codespace_model.Manager{ + Name: "manager", + UserID: 0, + RuntimeState: codespace_model.ManagerRuntimeStateOnline, + TagsJSON: "[]", + CreatedUnix: 1, + LastOnlineUnix: 1, + } + manager.GenerateManagerSecret() + require.NoError(t, db.Insert(t.Context(), manager)) + return manager +} + +func insertServiceCodespace(t *testing.T, managerID int64, codespace *codespace_model.Codespace) { + t.Helper() + codespace.ManagerID = managerID + codespace.UserID = 1 + codespace.RepoID = 2 + codespace.RefType = "branch" + codespace.RefName = "main" + if codespace.EnvironmentTag == "" { + codespace.EnvironmentTag = "default" + } + codespace.CommitSHA = "0123456789abcdef0123456789abcdef01234567" + codespace.DevContainerDefaultImage = "mcr.microsoft.com/devcontainers/base:ubuntu" + if codespace.AutoStopMode == "" { + codespace.AutoStopMode = codespace_model.AutoStopModeDefault + } + codespace.CreatedUnix = 1 + codespace.UpdatedUnix = 1 + require.NoError(t, db.Insert(t.Context(), codespace)) +} + +func insertServiceCredentials(t *testing.T, codespaceUUID string) { + t.Helper() + _, err := insertNewGiteaToken(t.Context(), codespaceUUID) + require.NoError(t, err) + require.NoError(t, db.Insert(t.Context(), &codespace_model.SSHKey{ + CodespaceUUID: codespaceUUID, + KeyID: time.Now().UnixNano(), + })) +} + +func loadServiceCodespace(t *testing.T, codespaceUUID string) *codespace_model.Codespace { + t.Helper() + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(t.Context()).ID(codespaceUUID).Get(codespace) + require.NoError(t, err) + require.True(t, has) + return codespace +} + +func assertServiceExists(t *testing.T, bean any, query string, args ...any) { + t.Helper() + has, err := db.GetEngine(t.Context()).Where(query, args...).Exist(bean) + require.NoError(t, err) + assert.True(t, has) +} + +func assertServiceNotExists(t *testing.T, bean any, query string, args ...any) { + t.Helper() + has, err := db.GetEngine(t.Context()).Where(query, args...).Exist(bean) + require.NoError(t, err) + assert.False(t, has) +} diff --git a/services/codespace/log.go b/services/codespace/log.go new file mode 100644 index 0000000000000..cb78a9f9aafa4 --- /dev/null +++ b/services/codespace/log.go @@ -0,0 +1,529 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "regexp" + "strings" + "time" + "unicode/utf8" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/dbfs" + "gitea.dev/modules/globallock" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" +) + +const ( + codespaceLogDBFSPrefix = "codespace_log/" + codespaceLogTruncationMessage = "Codespace log output was truncated because the log size limit was reached." + codespaceLogMaxTimestampPadding = int64(len("[] \n") + len(time.RFC3339Nano)) + codespaceLogMaxLineSize = int64(64 * 1024) + codespaceLogInternalSummaryReserve = int64(64 * 1024) +) + +// LogReadMaxBytes is the maximum bytes returned by one Codespace log page. +const LogReadMaxBytes = int64(512 * 1024) + +var ( + codespaceLogTokenPattern = regexp.MustCompile(`\bgcs_[0-9a-f]{64}\b`) + codespaceLogAuthorizationPattern = regexp.MustCompile(`(?i)(authorization:\s*(?:bearer|basic)\s+)[^\s]+`) + codespaceLogBearerBasicPattern = regexp.MustCompile(`(?i)\b((?:bearer|basic)\s+)[A-Za-z0-9._~+/=-]+`) + codespaceLogURLUserinfoPattern = regexp.MustCompile(`(?i)([a-z][a-z0-9+.-]*://)[^/@\s]+@`) + codespaceLogURLTokenPattern = regexp.MustCompile(`(?i)([?&](?:access_token|auth_token|private_token|token)=)[^&#\s]+`) + + // ErrUpdateLogNotFound is returned when the Codespace no longer exists. + ErrUpdateLogNotFound = errors.New("codespace not found") + // ErrUpdateLogStaleOperation is returned when the request no longer matches the active operation. + ErrUpdateLogStaleOperation = errors.New("codespace log operation is stale") + // ErrUpdateLogOffsetConflict is returned when the request offset overlaps different existing bytes. + ErrUpdateLogOffsetConflict = errors.New("codespace log offset conflict") + // ErrUpdateLogOffsetGap is returned when the request offset is beyond current file end. + ErrUpdateLogOffsetGap = errors.New("codespace log offset gap") + // ErrUpdateLogSizeExceeded is returned when ordinary log bytes have reached their reserved limit. + ErrUpdateLogSizeExceeded = errors.New("codespace log size exceeded") + // ErrReadLogNotFound is returned when the Codespace no longer exists. + ErrReadLogNotFound = errors.New("codespace log codespace not found") + // ErrReadLogPermissionDenied is returned when the user cannot read this Codespace log. + ErrReadLogPermissionDenied = errors.New("codespace log permission denied") + // ErrReadLogInvalidArgument is returned when read options are outside the supported range. + ErrReadLogInvalidArgument = errors.New("codespace log invalid argument") + // ErrReadLogOffsetConflict is returned when the requested offset is not a physical line boundary. + ErrReadLogOffsetConflict = errors.New("codespace log read offset conflict") +) + +// UpdateLogOptions identifies one log append request. +type UpdateLogOptions struct { + CodespaceUUID string + OperationRVersion int64 + Offset int64 + Lines []*codespacev1.LogLine +} + +// ReadLogOptions identifies one user-facing log page request. +type ReadLogOptions struct { + UserID int64 + CodespaceUUID string + Offset int64 + Limit int64 +} + +// ReadLogLine contains one parsed user-facing log line. +type ReadLogLine struct { + Timestamp float64 `json:"timestamp"` + Message string `json:"message"` + TimestampUnixNano int64 `json:"-"` +} + +// Encoded returns the canonical DBFS representation of the line. +func (line ReadLogLine) Encoded() string { + return encodeLogLine(line.TimestampUnixNano, line.Message) +} + +// ReadLogResult contains one byte-offset based log page. +type ReadLogResult struct { + Offset int64 `json:"offset"` + NextOffset int64 `json:"next_offset"` + EOF bool `json:"eof"` + OperationActive bool `json:"operation_active"` + Lines []ReadLogLine `json:"lines"` + Truncated bool `json:"truncated"` +} + +type internalStateSummary struct { + CodespaceUUID string + Message string +} + +// LogOffsetError carries the current server-authoritative log offset. +type LogOffsetError struct { + Err error + CurrentOffset int64 +} + +func (e *LogOffsetError) Error() string { + return e.Err.Error() +} + +func (e *LogOffsetError) Unwrap() error { + return e.Err +} + +// UpdateLog appends Manager operation logs to the Codespace DBFS log file. +func UpdateLog(ctx context.Context, manager *codespace_model.Manager, opts UpdateLogOptions) (*codespacev1.UpdateLogResponse, error) { + if manager == nil || manager.ID <= 0 { + return nil, errors.New("manager is required") + } + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return nil, err + } + if opts.OperationRVersion <= 0 { + return nil, errors.New("operation_rversion must be positive") + } + if opts.Offset < 0 { + return nil, errors.New("offset must not be negative") + } + encoded, err := encodeLogLines(opts.Lines) + if err != nil { + return nil, err + } + + var nextOffset int64 + var sizeExceeded bool + err = globallock.LockAndDo(ctx, updateLogLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceUUID).Get(codespace) + if err != nil { + return err + } + if !has { + return ErrUpdateLogNotFound + } + if !isCurrentRunningOperation(codespace, manager.ID, opts.OperationRVersion) { + return ErrUpdateLogStaleOperation + } + logFilename := codespaceLogFilename(codespace.UUID) + if opts.Offset > codespace.LogSize { + return &LogOffsetError{Err: ErrUpdateLogOffsetGap, CurrentOffset: codespace.LogSize} + } + if opts.Offset < codespace.LogSize { + ok, err := logReplayMatches(ctx, logFilename, opts.Offset, encoded) + if err != nil { + return err + } + if !ok { + return &LogOffsetError{Err: ErrUpdateLogOffsetConflict, CurrentOffset: codespace.LogSize} + } + nextOffset = codespace.LogSize + return nil + } + if len(encoded) == 0 { + nextOffset = codespace.LogSize + return nil + } + hasTruncationSummary, err := codespaceLogHasTruncationSummary(ctx, logFilename, codespace.LogSize) + if err != nil { + return err + } + if hasTruncationSummary { + nextOffset = codespace.LogSize + sizeExceeded = true + return nil + } + if codespace.LogSize >= codespaceLogOrdinaryLimit() || codespace.LogSize+int64(len(encoded)) > codespaceLogOrdinaryLimit() { + truncation, err := encodeLogLines([]*codespacev1.LogLine{{ + TimestampUnixNano: time.Now().UnixNano(), + Message: codespaceLogTruncationMessage, + }}) + if err != nil { + return err + } + if codespace.LogSize+int64(len(truncation)) > setting.Codespace.LogMaxSize { + return ErrUpdateLogSizeExceeded + } + if err := appendEncodedLogLines(ctx, codespace, truncation); err != nil { + return err + } + nextOffset = codespace.LogSize + sizeExceeded = true + return nil + } + if err := appendEncodedLogLines(ctx, codespace, encoded); err != nil { + return err + } + nextOffset = codespace.LogSize + return nil + }) + }) + if err != nil { + return nil, err + } + if sizeExceeded { + return nil, ErrUpdateLogSizeExceeded + } + return &codespacev1.UpdateLogResponse{NextOffset: nextOffset}, nil +} + +// ReadLog reads one complete-line page from the Codespace DBFS log file. +func ReadLog(ctx context.Context, opts ReadLogOptions) (*ReadLogResult, error) { + if opts.UserID <= 0 { + return nil, fmt.Errorf("%w: user_id must be positive", ErrReadLogInvalidArgument) + } + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return nil, fmt.Errorf("%w: %w", ErrReadLogInvalidArgument, err) + } + if opts.Offset < 0 { + return nil, &LogOffsetError{Err: ErrReadLogInvalidArgument, CurrentOffset: 0} + } + if opts.Limit <= 0 || opts.Limit > LogReadMaxBytes { + return nil, fmt.Errorf("%w: limit must be between 1 and %d", ErrReadLogInvalidArgument, LogReadMaxBytes) + } + + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceUUID).Get(codespace) + if err != nil { + return nil, err + } + if !has { + return nil, ErrReadLogNotFound + } + if codespace.UserID != opts.UserID { + return nil, ErrReadLogPermissionDenied + } + if opts.Offset > codespace.LogSize { + return nil, &LogOffsetError{Err: ErrReadLogOffsetConflict, CurrentOffset: codespace.LogSize} + } + if opts.Offset == codespace.LogSize { + return &ReadLogResult{Offset: opts.Offset, NextOffset: opts.Offset, EOF: true, OperationActive: hasActiveOperation(codespace), Lines: []ReadLogLine{}}, nil + } + lines, nextOffset, eof, truncated, err := readLogLines(ctx, codespaceLogFilename(codespace.UUID), opts.Offset, codespace.LogSize, opts.Limit) + if err != nil { + return nil, err + } + return &ReadLogResult{ + Offset: opts.Offset, + NextOffset: nextOffset, + EOF: eof, + OperationActive: hasActiveOperation(codespace), + Lines: lines, + Truncated: truncated, + }, nil +} + +func encodeLogLines(lines []*codespacev1.LogLine) ([]byte, error) { + var buf bytes.Buffer + for _, line := range lines { + if line == nil { + return nil, errors.New("log line is required") + } + if line.GetTimestampUnixNano() <= 0 { + return nil, errors.New("log timestamp must be positive") + } + if !utf8.ValidString(line.GetMessage()) { + return nil, errors.New("log message must be valid UTF-8") + } + if strings.ContainsAny(line.GetMessage(), "\r\n") { + return nil, errors.New("log message must not contain newline") + } + if int64(len(line.GetMessage())) > codespaceLogMaxLineSize { + return nil, errors.New("log message exceeds maximum line size") + } + message := line.GetMessage() + message = codespaceLogTokenPattern.ReplaceAllString(message, "[redacted]") + message = codespaceLogAuthorizationPattern.ReplaceAllString(message, "${1}[redacted]") + message = codespaceLogBearerBasicPattern.ReplaceAllString(message, "${1}[redacted]") + message = codespaceLogURLUserinfoPattern.ReplaceAllString(message, "${1}[redacted]@") + message = codespaceLogURLTokenPattern.ReplaceAllString(message, "${1}[redacted]") + message = strings.Map(func(r rune) rune { + if r < 0x20 && r != '\t' || r == 0x7f { + return -1 + } + return r + }, message) + if int64(len(message)) > codespaceLogMaxLineSize { + return nil, errors.New("log message exceeds maximum line size") + } + encoded := encodeLogLine(line.GetTimestampUnixNano(), message) + if int64(len(encoded)) > codespaceLogMaxLineSize+codespaceLogMaxTimestampPadding { + return nil, errors.New("encoded log line exceeds maximum line size") + } + buf.WriteString(encoded) + } + return buf.Bytes(), nil +} + +func encodeLogLine(timestampUnixNano int64, message string) string { + return fmt.Sprintf("[%s] %s\n", time.Unix(0, timestampUnixNano).UTC().Format(time.RFC3339Nano), message) +} + +func appendEncodedLogLines(ctx context.Context, codespace *codespace_model.Codespace, encoded []byte) error { + if len(encoded) == 0 { + return nil + } + if err := appendLogBytes(ctx, codespaceLogFilename(codespace.UUID), codespace.LogSize, encoded); err != nil { + return err + } + codespace.LogSize += int64(len(encoded)) + _, err := db.GetEngine(ctx).ID(codespace.UUID).Cols("log_size").Update(codespace) + return err +} + +func appendInternalStateSummary(ctx context.Context, summary *internalStateSummary) { + if summary == nil || summary.Message == "" { + return + } + err := globallock.LockAndDo(ctx, updateLogLockKey(summary.CodespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(summary.CodespaceUUID).Get(codespace) + if err != nil || !has { + return err + } + encoded, err := encodeLogLines([]*codespacev1.LogLine{{ + TimestampUnixNano: time.Now().UnixNano(), + Message: summary.Message, + }}) + if err != nil { + return err + } + if codespace.LogSize+int64(len(encoded)) > setting.Codespace.LogMaxSize { + return ErrUpdateLogSizeExceeded + } + return appendEncodedLogLines(ctx, codespace, encoded) + }) + }) + if err != nil { + log.Warn("failed to write codespace internal state summary for %s: %v", summary.CodespaceUUID, err) + } +} + +func operationTimeoutSummary(codespace *codespace_model.Codespace, resultStatus string) *internalStateSummary { + return &internalStateSummary{ + CodespaceUUID: codespace.UUID, + Message: fmt.Sprintf("Gitea recorded operation %s#%d timeout as %s.", + codespace.OperationType, codespace.OperationRVersion, resultStatus), + } +} + +func runtimeMissingSummary(codespace *codespace_model.Codespace) *internalStateSummary { + return &internalStateSummary{ + CodespaceUUID: codespace.UUID, + Message: "Gitea recorded missing runtime as failed.", + } +} + +func codespaceLogHasTruncationSummary(ctx context.Context, filename string, logSize int64) (bool, error) { + if filename == "" || logSize <= 0 { + return false, nil + } + suffix := []byte(codespaceLogTruncationMessage + "\n") + readSize := int64(len(suffix)) + if logSize < readSize { + return false, nil + } + file, err := dbfs.Open(ctx, codespaceLogDBFSPrefix+filename) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + defer file.Close() + if _, err := file.Seek(logSize-readSize, io.SeekStart); err != nil { + return false, err + } + actual := make([]byte, readSize) + if _, err := io.ReadFull(file, actual); err != nil { + return false, err + } + return bytes.Equal(actual, suffix), nil +} + +func logReplayMatches(ctx context.Context, filename string, offset int64, expected []byte) (bool, error) { + if len(expected) == 0 { + return true, nil + } + file, err := dbfs.Open(ctx, codespaceLogDBFSPrefix+filename) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + defer file.Close() + if _, err := file.Seek(offset, io.SeekStart); err != nil { + return false, err + } + actual := make([]byte, len(expected)) + n, err := io.ReadFull(file, actual) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return false, err + } + return n == len(expected) && bytes.Equal(actual, expected), nil +} + +func appendLogBytes(ctx context.Context, filename string, offset int64, data []byte) error { + flag := os.O_RDWR + if offset == 0 { + flag |= os.O_CREATE + } + file, err := dbfs.OpenFile(ctx, codespaceLogDBFSPrefix+filename, flag) + if err != nil { + return fmt.Errorf("open codespace log: %w", err) + } + defer file.Close() + stat, err := file.Stat() + if err != nil { + return fmt.Errorf("stat codespace log: %w", err) + } + if stat.Size() != offset { + return &LogOffsetError{Err: ErrUpdateLogOffsetConflict, CurrentOffset: stat.Size()} + } + if _, err := file.Seek(offset, io.SeekStart); err != nil { + return err + } + n, err := file.Write(data) + if err != nil { + return err + } + if n != len(data) { + return io.ErrShortWrite + } + return nil +} + +func readLogLines(ctx context.Context, filename string, offset, logSize, limit int64) ([]ReadLogLine, int64, bool, bool, error) { + file, err := dbfs.Open(ctx, codespaceLogDBFSPrefix+filename) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, 0, false, false, ErrReadLogNotFound + } + return nil, 0, false, false, err + } + defer file.Close() + if _, err := file.Seek(offset, io.SeekStart); err != nil { + return nil, 0, false, false, err + } + + reader := bufio.NewReaderSize(io.LimitReader(file, logSize-offset), int(codespaceLogMaxLineSize)) + lines := make([]ReadLogLine, 0) + nextOffset := offset + readBytes := int64(0) + for nextOffset < logSize { + line, readErr := reader.ReadString('\n') + if readErr != nil && !errors.Is(readErr, io.EOF) { + return nil, 0, false, false, readErr + } + if line == "" && errors.Is(readErr, io.EOF) { + break + } + lineLen := int64(len(line)) + if lineLen > LogReadMaxBytes { + return nil, 0, false, false, errors.New("codespace log line exceeds read limit") + } + if len(lines) > 0 && readBytes+lineLen > limit { + return lines, nextOffset, false, true, nil + } + parsed, parseErr := parseEncodedLogLine(line) + if parseErr != nil { + return nil, 0, false, false, parseErr + } + lines = append(lines, parsed) + nextOffset += lineLen + readBytes += lineLen + if readBytes >= limit { + return lines, nextOffset, nextOffset >= logSize, nextOffset < logSize, nil + } + } + return lines, nextOffset, nextOffset >= logSize, false, nil +} + +func parseEncodedLogLine(line string) (ReadLogLine, error) { + if !strings.HasPrefix(line, "[") || !strings.HasSuffix(line, "\n") { + return ReadLogLine{}, errors.New("invalid codespace log line") + } + separator := strings.Index(line, "] ") + if separator < 0 { + return ReadLogLine{}, errors.New("invalid codespace log timestamp separator") + } + timestamp, err := time.Parse(time.RFC3339Nano, line[1:separator]) + if err != nil { + return ReadLogLine{}, fmt.Errorf("parse codespace log timestamp: %w", err) + } + return ReadLogLine{ + Timestamp: float64(timestamp.UnixNano()) / float64(time.Second), + Message: line[separator+2 : len(line)-1], + TimestampUnixNano: timestamp.UnixNano(), + }, nil +} + +func codespaceLogOrdinaryLimit() int64 { + return setting.Codespace.LogMaxSize - codespaceLogInternalSummaryReserve +} + +func updateLogLockKey(codespaceUUID string) string { + return "codespace_log_" + codespaceUUID +} + +func codespaceLogFilename(codespaceUUID string) string { + return codespaceUUID + ".log" +} + +func deleteCodespaceLog(ctx context.Context, codespaceUUID string) error { + if err := dbfs.Remove(ctx, codespaceLogDBFSPrefix+codespaceLogFilename(codespaceUUID)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} diff --git a/services/codespace/log_test.go b/services/codespace/log_test.go new file mode 100644 index 0000000000000..ac8f15964c9c2 --- /dev/null +++ b/services/codespace/log_test.go @@ -0,0 +1,421 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "io" + "strings" + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/dbfs" + "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdateLogAppendsAndReplaysIdempotently(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "67676767-6767-4676-8676-676767676767" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 22, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + lines := []*codespacev1.LogLine{ + {TimestampUnixNano: time.Date(2026, 7, 22, 1, 2, 3, 4, time.UTC).UnixNano(), Message: "prepare workspace"}, + {TimestampUnixNano: time.Date(2026, 7, 22, 1, 2, 4, 5, time.UTC).UnixNano(), Message: "ready"}, + } + + result, err := UpdateLog(t.Context(), manager, UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 22, + Offset: 0, + Lines: lines, + }) + require.NoError(t, err) + require.Positive(t, result.NextOffset) + row := loadServiceCodespace(t, codespaceUUID) + assert.Equal(t, result.NextOffset, row.LogSize) + content := readServiceLog(t, codespaceLogFilename(row.UUID)) + assert.Contains(t, content, "prepare workspace\n") + assert.Contains(t, content, "ready\n") + + beforeReplay := readServiceLog(t, codespaceLogFilename(row.UUID)) + replay, err := UpdateLog(t.Context(), manager, UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 22, + Offset: 0, + Lines: lines, + }) + require.NoError(t, err) + assert.Equal(t, result.NextOffset, replay.NextOffset) + assert.Equal(t, beforeReplay, readServiceLog(t, codespaceLogFilename(row.UUID))) +} + +func TestUpdateLogRedactsCommonCredentials(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "68686868-6868-4686-8686-686868686868" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 28, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + token := "gcs_" + strings.Repeat("a", 64) + lines := []*codespacev1.LogLine{{ + TimestampUnixNano: time.Date(2026, 7, 22, 1, 2, 5, 0, time.UTC).UnixNano(), + Message: "token=" + token + " Authorization: Bearer header-secret https://user:password@example.com/repo?access_token=query-secret\x01", + }} + + result, err := UpdateLog(t.Context(), manager, UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 28, + Lines: lines, + }) + require.NoError(t, err) + content := readServiceLog(t, codespaceLogFilename(codespaceUUID)) + assert.NotContains(t, content, token) + assert.NotContains(t, content, "header-secret") + assert.NotContains(t, content, "user:password") + assert.NotContains(t, content, "query-secret") + assert.NotContains(t, content, "\x01") + assert.Contains(t, content, "token=[redacted]") + + replay, err := UpdateLog(t.Context(), manager, UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 28, + Lines: lines, + }) + require.NoError(t, err) + assert.Equal(t, result.NextOffset, replay.NextOffset) + assert.Equal(t, content, readServiceLog(t, codespaceLogFilename(codespaceUUID))) +} + +func TestUpdateLogRejectsOffsetGapAndConflict(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "78787878-7878-4787-8787-787878787878" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 23, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + first, err := UpdateLog(t.Context(), manager, UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 23, + Offset: 0, + Lines: []*codespacev1.LogLine{{ + TimestampUnixNano: time.Date(2026, 7, 22, 1, 3, 0, 0, time.UTC).UnixNano(), + Message: "first", + }}, + }) + require.NoError(t, err) + + _, err = UpdateLog(t.Context(), manager, UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 23, + Offset: first.NextOffset + 1, + Lines: []*codespacev1.LogLine{{ + TimestampUnixNano: time.Date(2026, 7, 22, 1, 3, 1, 0, time.UTC).UnixNano(), + Message: "gap", + }}, + }) + var offsetErr *LogOffsetError + require.ErrorAs(t, err, &offsetErr) + assert.ErrorIs(t, err, ErrUpdateLogOffsetGap) + assert.Equal(t, first.NextOffset, offsetErr.CurrentOffset) + + _, err = UpdateLog(t.Context(), manager, UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 23, + Offset: 0, + Lines: []*codespacev1.LogLine{{ + TimestampUnixNano: time.Date(2026, 7, 22, 1, 3, 0, 0, time.UTC).UnixNano(), + Message: "different", + }}, + }) + require.ErrorAs(t, err, &offsetErr) + assert.ErrorIs(t, err, ErrUpdateLogOffsetConflict) + assert.Equal(t, first.NextOffset, offsetErr.CurrentOffset) +} + +func TestUpdateLogRejectsStaleOperation(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + otherManager := insertServiceManager(t) + codespaceUUID := "89898989-8989-4898-8989-898989898989" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 24, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + + _, err := UpdateLog(t.Context(), otherManager, UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 24, + Offset: 0, + Lines: []*codespacev1.LogLine{{ + TimestampUnixNano: time.Now().UnixNano(), + Message: "stale", + }}, + }) + require.ErrorIs(t, err, ErrUpdateLogStaleOperation) + assert.Zero(t, loadServiceCodespace(t, codespaceUUID).LogSize) +} + +func TestUpdateLogAppendsTruncationSummaryOnce(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + t.Cleanup(test.MockVariableValue(&setting.Codespace.LogMaxSize, codespaceLogInternalSummaryReserve+260)) + + manager := insertServiceManager(t) + codespaceUUID := "93909090-9090-4090-8090-909090909090" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 27, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + first, err := UpdateLog(t.Context(), manager, UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 27, + Offset: 0, + Lines: []*codespacev1.LogLine{{ + TimestampUnixNano: time.Date(2026, 7, 22, 1, 4, 0, 0, time.UTC).UnixNano(), + Message: strings.Repeat("a", 100), + }}, + }) + require.NoError(t, err) + assert.Less(t, first.NextOffset, codespaceLogOrdinaryLimit()) + + _, err = UpdateLog(t.Context(), manager, UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 27, + Offset: first.NextOffset, + Lines: []*codespacev1.LogLine{ + { + TimestampUnixNano: time.Date(2026, 7, 22, 1, 4, 1, 0, time.UTC).UnixNano(), + Message: strings.Repeat("b", 100), + }, + { + TimestampUnixNano: time.Date(2026, 7, 22, 1, 4, 2, 0, time.UTC).UnixNano(), + Message: strings.Repeat("c", 100), + }, + }, + }) + require.ErrorIs(t, err, ErrUpdateLogSizeExceeded) + row := loadServiceCodespace(t, codespaceUUID) + require.LessOrEqual(t, row.LogSize, setting.Codespace.LogMaxSize) + content := readServiceLog(t, codespaceLogFilename(row.UUID)) + assert.NotContains(t, content, strings.Repeat("b", 100)) + assert.NotContains(t, content, strings.Repeat("c", 100)) + assert.Equal(t, 1, strings.Count(content, codespaceLogTruncationMessage)) + + sizeAfterSummary := row.LogSize + _, err = UpdateLog(t.Context(), manager, UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 27, + Offset: row.LogSize, + Lines: []*codespacev1.LogLine{{ + TimestampUnixNano: time.Date(2026, 7, 22, 1, 4, 3, 0, time.UTC).UnixNano(), + Message: "after limit", + }}, + }) + require.ErrorIs(t, err, ErrUpdateLogSizeExceeded) + row = loadServiceCodespace(t, codespaceUUID) + assert.Equal(t, sizeAfterSummary, row.LogSize) + assert.Equal(t, content, readServiceLog(t, codespaceLogFilename(row.UUID))) +} + +func TestReadLogPagesByReturnedOffset(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "90909090-9090-4090-8090-909090909090" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 25, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + result, err := UpdateLog(t.Context(), manager, UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 25, + Offset: 0, + Lines: []*codespacev1.LogLine{ + {TimestampUnixNano: time.Date(2026, 7, 22, 2, 0, 0, 0, time.UTC).UnixNano(), Message: "first"}, + {TimestampUnixNano: time.Date(2026, 7, 22, 2, 0, 1, 0, time.UTC).UnixNano(), Message: "second"}, + {TimestampUnixNano: time.Date(2026, 7, 22, 2, 0, 2, 0, time.UTC).UnixNano(), Message: "third"}, + }, + }) + require.NoError(t, err) + + firstPage, err := ReadLog(t.Context(), ReadLogOptions{ + UserID: 1, + CodespaceUUID: codespaceUUID, + Offset: 0, + Limit: 1, + }) + require.NoError(t, err) + assert.EqualValues(t, 0, firstPage.Offset) + assert.False(t, firstPage.EOF) + assert.True(t, firstPage.OperationActive) + assert.True(t, firstPage.Truncated) + require.Len(t, firstPage.Lines, 1) + assert.Equal(t, "first", firstPage.Lines[0].Message) + + secondPage, err := ReadLog(t.Context(), ReadLogOptions{ + UserID: 1, + CodespaceUUID: codespaceUUID, + Offset: firstPage.NextOffset, + Limit: 1, + }) + require.NoError(t, err) + assert.False(t, secondPage.EOF) + assert.True(t, secondPage.Truncated) + require.Len(t, secondPage.Lines, 1) + assert.Equal(t, "second", secondPage.Lines[0].Message) + + eofPage, err := ReadLog(t.Context(), ReadLogOptions{ + UserID: 1, + CodespaceUUID: codespaceUUID, + Offset: result.NextOffset, + Limit: LogReadMaxBytes, + }) + require.NoError(t, err) + assert.True(t, eofPage.EOF) + assert.True(t, eofPage.OperationActive) + assert.Empty(t, eofPage.Lines) +} + +func TestReadLogReportsOperationActivity(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "68686868-6868-4686-8686-686868686868" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusStopped, + }) + + result, err := ReadLog(t.Context(), ReadLogOptions{ + UserID: 1, + CodespaceUUID: codespaceUUID, + Limit: LogReadMaxBytes, + }) + require.NoError(t, err) + assert.True(t, result.EOF) + assert.False(t, result.OperationActive) +} + +func TestReadLogRequiresCreator(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "69696969-6969-4696-8696-696969696968" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + }) + + _, err := ReadLog(t.Context(), ReadLogOptions{ + UserID: 2, + CodespaceUUID: codespaceUUID, + Limit: LogReadMaxBytes, + }) + require.ErrorIs(t, err, ErrReadLogPermissionDenied) +} + +func TestReadLogRejectsOffsetPastEOF(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "91909090-9090-4090-8090-909090909090" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 26, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + result, err := UpdateLog(t.Context(), manager, UpdateLogOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 26, + Offset: 0, + Lines: []*codespacev1.LogLine{{ + TimestampUnixNano: time.Date(2026, 7, 22, 2, 1, 0, 0, time.UTC).UnixNano(), + Message: "line", + }}, + }) + require.NoError(t, err) + + _, err = ReadLog(t.Context(), ReadLogOptions{ + UserID: 1, + CodespaceUUID: codespaceUUID, + Offset: result.NextOffset + 1, + Limit: LogReadMaxBytes, + }) + var offsetErr *LogOffsetError + require.ErrorAs(t, err, &offsetErr) + assert.ErrorIs(t, err, ErrReadLogOffsetConflict) + assert.Equal(t, result.NextOffset, offsetErr.CurrentOffset) +} + +func readServiceLog(t *testing.T, filename string) string { + t.Helper() + file, err := dbfs.Open(t.Context(), codespaceLogDBFSPrefix+filename) + require.NoError(t, err) + defer file.Close() + data, err := io.ReadAll(file) + require.NoError(t, err) + return string(data) +} diff --git a/services/codespace/main_test.go b/services/codespace/main_test.go new file mode 100644 index 0000000000000..2bbf7d731f95e --- /dev/null +++ b/services/codespace/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + + "gitea.dev/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/services/codespace/manager.go b/services/codespace/manager.go new file mode 100644 index 0000000000000..c0397e8090923 --- /dev/null +++ b/services/codespace/manager.go @@ -0,0 +1,328 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + user_model "gitea.dev/models/user" + "gitea.dev/modules/globallock" + "gitea.dev/modules/json" + "gitea.dev/modules/setting" +) + +var ( + tagPattern = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`) + sshHostKeyFingerprintRegexp = regexp.MustCompile(`^SHA256:[A-Za-z0-9+/]+={0,2}$`) +) + +const managerMaxEnvironments = 64 + +var ( + // ErrRegistrationUnauthenticated is returned when a registration token is not current. + ErrRegistrationUnauthenticated = errors.New("manager registration unauthenticated") + // ErrRegistrationStateUnavailable is returned when Codespace is not accepting new Manager registrations. + ErrRegistrationStateUnavailable = errors.New("manager registration state unavailable") + // ErrManagerUnregistered is returned when the Manager ID has no current row. + ErrManagerUnregistered = errors.New("manager unregistered") + // ErrManagerUnauthenticated is returned when the Manager credential is not valid. + ErrManagerUnauthenticated = errors.New("manager unauthenticated") + // ErrDeclareGatewayURLConflict is returned when another Manager already uses the Gateway URL. + ErrDeclareGatewayURLConflict = errors.New("manager gateway url conflict") + // ErrDeclareGatewaySSHAddrConflict is returned when another Manager already uses the Gateway SSH address. + ErrDeclareGatewaySSHAddrConflict = errors.New("manager gateway ssh address conflict") +) + +// DeclareManagerOptions contains the full Manager declaration accepted by Gitea. +type DeclareManagerOptions struct { + GatewayURL string + GatewaySSHAddr string + Environments []*codespacev1.EnvironmentTag + Version string + Name string + RuntimeState codespacev1.ManagerRuntimeState + GatewaySSHHostKeyAlgorithm string + GatewaySSHHostKeyFingerprintSHA256 string + GatewaySSHHostKeyUpdatedUnix int64 +} + +// ManagerEnvironmentDeclaration is one environment in a Manager declaration snapshot. +type ManagerEnvironmentDeclaration struct { + Tag string `json:"tag"` + Description string `json:"description,omitempty"` +} + +// RegisterManager exchanges the current site or personal registration token for a Manager identity. +func RegisterManager(ctx context.Context, registrationToken string) (*codespace_model.Manager, string, error) { + if !setting.Codespace.Enabled { + return nil, "", ErrRegistrationStateUnavailable + } + registrationToken = strings.TrimSpace(registrationToken) + if registrationToken == "" { + return nil, "", ErrRegistrationUnauthenticated + } + + token, err := loadRegistrationTokenByValue(ctx, registrationToken) + if err != nil { + return nil, "", err + } + if token == nil { + return nil, "", ErrRegistrationUnauthenticated + } + + var manager *codespace_model.Manager + var secret string + err = globallock.LockAndDo(ctx, codespaceUserRelationLockKey(token.UserID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + currentToken, err := loadRegistrationTokenByValue(ctx, registrationToken) + if err != nil { + return err + } + if currentToken == nil || currentToken.UserID != token.UserID { + return ErrRegistrationUnauthenticated + } + if currentToken.UserID > 0 { + user, err := user_model.GetUserByID(ctx, currentToken.UserID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + return ErrRegistrationUnauthenticated + } + return err + } + if user.Type != user_model.UserTypeIndividual { + return ErrRegistrationUnauthenticated + } + } + manager = &codespace_model.Manager{ + UserID: currentToken.UserID, + RuntimeState: codespace_model.ManagerRuntimeStateRecovering, + TagsJSON: "[]", + CreatedUnix: time.Now().Unix(), + } + secret = manager.GenerateManagerSecret() + if _, err := db.GetEngine(ctx).Insert(manager); err != nil { + return err + } + return nil + }) + }) + if err != nil { + return nil, "", err + } + return manager, secret, nil +} + +// AuthenticateManager verifies a Manager id and plaintext secret. +func AuthenticateManager(ctx context.Context, managerID int64, secret string) (*codespace_model.Manager, error) { + if managerID <= 0 || strings.TrimSpace(secret) == "" { + return nil, ErrManagerUnauthenticated + } + manager := new(codespace_model.Manager) + has, err := db.GetEngine(ctx).ID(managerID).Get(manager) + if err != nil { + return nil, err + } + if !has { + return nil, ErrManagerUnregistered + } + if !manager.VerifyManagerSecret(secret) { + return nil, ErrManagerUnauthenticated + } + return manager, nil +} + +// DeclareManager stores the latest Manager declaration and replaces routable addresses atomically. +func DeclareManager(ctx context.Context, manager *codespace_model.Manager, opts DeclareManagerOptions) error { + if manager == nil || manager.ID <= 0 { + return errors.New("manager is required") + } + normalizedOpts, err := normalizeDeclareManagerOptions(opts) + if err != nil { + return err + } + opts = normalizedOpts + + environments, err := normalizeManagerEnvironments(opts.Environments) + if err != nil { + return err + } + tagsJSON, err := json.Marshal(environments) + if err != nil { + return fmt.Errorf("encode tags: %w", err) + } + warnGatewayCookieScopeConflict(manager.ID, opts.GatewayURL) + + return globallock.LockAndDo(ctx, fetchManagerLockKey(manager.ID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + currentManager := new(codespace_model.Manager) + has, err := db.GetEngine(ctx).ID(manager.ID).Get(currentManager) + if err != nil { + return err + } + if !has { + return ErrManagerUnregistered + } + if err := checkManagerAddressConflict(ctx, currentManager.ID, codespace_model.ManagerAddressGateway, opts.GatewayURL); err != nil { + return err + } + if err := checkManagerAddressConflict(ctx, currentManager.ID, codespace_model.ManagerAddressSSH, opts.GatewaySSHAddr); err != nil { + return err + } + + now := time.Now().Unix() + updates := &codespace_model.Manager{ + Name: opts.Name, + TagsJSON: string(tagsJSON), + RuntimeState: managerRuntimeStateName(opts.RuntimeState), + LastOnlineUnix: now, + Version: opts.Version, + GatewaySSHHostKeyAlgorithm: opts.GatewaySSHHostKeyAlgorithm, + GatewaySSHHostKeyFingerprintSHA256: opts.GatewaySSHHostKeyFingerprintSHA256, + GatewaySSHHostKeyUpdatedUnix: opts.GatewaySSHHostKeyUpdatedUnix, + } + affected, err := db.GetEngine(ctx).ID(currentManager.ID).Cols( + "name", "tags_json", "runtime_state", "last_online_unix", "version", + "gateway_ssh_host_key_algorithm", "gateway_ssh_host_key_fingerprint_sha256", "gateway_ssh_host_key_updated_unix", + ).Update(updates) + if err != nil { + return err + } + if affected == 0 { + return ErrManagerUnregistered + } + if _, err := db.GetEngine(ctx).Where("manager_id = ?", currentManager.ID).Delete(new(codespace_model.ManagerAddress)); err != nil { + return err + } + addresses := []*codespace_model.ManagerAddress{ + {ManagerID: currentManager.ID, Kind: codespace_model.ManagerAddressGateway, Address: opts.GatewayURL}, + {ManagerID: currentManager.ID, Kind: codespace_model.ManagerAddressSSH, Address: opts.GatewaySSHAddr}, + } + if _, err := db.GetEngine(ctx).Insert(addresses); err != nil { + return err + } + return nil + }) + }) +} + +func checkManagerAddressConflict(ctx context.Context, managerID int64, kind, address string) error { + existing := new(codespace_model.ManagerAddress) + has, err := db.GetEngine(ctx). + Where("kind = ? AND address = ? AND manager_id <> ?", kind, address, managerID). + Get(existing) + if err != nil || !has { + return err + } + switch kind { + case codespace_model.ManagerAddressGateway: + return ErrDeclareGatewayURLConflict + case codespace_model.ManagerAddressSSH: + return ErrDeclareGatewaySSHAddrConflict + default: + return errors.New("manager address conflict") + } +} + +func normalizeDeclareManagerOptions(opts DeclareManagerOptions) (DeclareManagerOptions, error) { + opts.Name = strings.TrimSpace(opts.Name) + if opts.Name == "" { + return opts, errors.New("manager name is required") + } + if len(opts.Name) > 255 { + return opts, errors.New("manager name is too long") + } + opts.Version = strings.TrimSpace(opts.Version) + if opts.Version == "" { + return opts, errors.New("manager version is required") + } + if len(opts.Version) > 64 { + return opts, errors.New("manager version is too long") + } + if opts.RuntimeState != codespacev1.ManagerRuntimeState_MANAGER_RUNTIME_STATE_ONLINE && opts.RuntimeState != codespacev1.ManagerRuntimeState_MANAGER_RUNTIME_STATE_RECOVERING { + return opts, fmt.Errorf("invalid manager runtime state %d", opts.RuntimeState) + } + gatewayURL, err := normalizeGatewayURL(opts.GatewayURL) + if err != nil { + return opts, err + } + opts.GatewayURL = gatewayURL + gatewaySSHAddr, err := normalizeGatewaySSHAddr(opts.GatewaySSHAddr) + if err != nil { + return opts, err + } + opts.GatewaySSHAddr = gatewaySSHAddr + opts.GatewaySSHHostKeyAlgorithm = strings.TrimSpace(opts.GatewaySSHHostKeyAlgorithm) + if opts.GatewaySSHHostKeyAlgorithm == "" { + return opts, errors.New("gateway ssh host key algorithm is required") + } + if len(opts.GatewaySSHHostKeyAlgorithm) > 64 { + return opts, errors.New("gateway ssh host key algorithm is too long") + } + opts.GatewaySSHHostKeyFingerprintSHA256 = strings.TrimSpace(opts.GatewaySSHHostKeyFingerprintSHA256) + if !sshHostKeyFingerprintRegexp.MatchString(opts.GatewaySSHHostKeyFingerprintSHA256) { + return opts, errors.New("invalid gateway ssh host key fingerprint") + } + if opts.GatewaySSHHostKeyUpdatedUnix < 0 { + return opts, errors.New("gateway ssh host key updated time must not be negative") + } + return opts, nil +} + +func managerRuntimeStateName(state codespacev1.ManagerRuntimeState) string { + switch state { + case codespacev1.ManagerRuntimeState_MANAGER_RUNTIME_STATE_ONLINE: + return codespace_model.ManagerRuntimeStateOnline + case codespacev1.ManagerRuntimeState_MANAGER_RUNTIME_STATE_RECOVERING: + return codespace_model.ManagerRuntimeStateRecovering + default: + return "" + } +} + +func normalizeManagerEnvironments(environments []*codespacev1.EnvironmentTag) ([]ManagerEnvironmentDeclaration, error) { + if len(environments) == 0 { + return nil, errors.New("manager environments are required") + } + if len(environments) > managerMaxEnvironments { + return nil, errors.New("manager environments exceed 64") + } + normalized := make([]ManagerEnvironmentDeclaration, 0, len(environments)) + seen := make(map[string]struct{}, len(environments)) + for _, environment := range environments { + if environment == nil { + return nil, errors.New("manager environment is required") + } + tag := strings.ToLower(strings.TrimSpace(environment.GetTag())) + if !tagPattern.MatchString(tag) { + return nil, fmt.Errorf("invalid manager environment tag %q", tag) + } + if _, exists := seen[tag]; exists { + return nil, fmt.Errorf("manager environment tag %q is duplicated", tag) + } + seen[tag] = struct{}{} + description := strings.TrimSpace(environment.GetDescription()) + if len(description) > 255 { + return nil, fmt.Errorf("manager environment %q description is too long", tag) + } + normalized = append(normalized, ManagerEnvironmentDeclaration{Tag: tag, Description: description}) + } + return normalized, nil +} + +func loadRegistrationTokenByValue(ctx context.Context, tokenValue string) (*codespace_model.ManagerToken, error) { + token := new(codespace_model.ManagerToken) + has, err := db.GetEngine(ctx).Where("token = ?", tokenValue).Get(token) + if err != nil || !has { + return nil, err + } + return token, nil +} diff --git a/services/codespace/manager_gateway.go b/services/codespace/manager_gateway.go new file mode 100644 index 0000000000000..b8c0a0f770fc5 --- /dev/null +++ b/services/codespace/manager_gateway.go @@ -0,0 +1,196 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "regexp" + "strconv" + "strings" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" + + "golang.org/x/net/publicsuffix" +) + +var dnsLabelPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`) + +// WarnManagerGatewayAddressConflicts reports stored Gateway base domains that overlap the current site cookie scope. +func WarnManagerGatewayAddressConflicts(ctx context.Context) error { + var addresses []*codespace_model.ManagerAddress + if err := db.GetEngine(ctx).Where("kind = ?", codespace_model.ManagerAddressGateway).Find(&addresses); err != nil { + return err + } + for _, address := range addresses { + normalized, err := normalizeGatewayURL(address.Address) + if err != nil { + log.Warn("Codespace Manager Gateway address is invalid in database: manager_id=%d gateway_url=%q error=%v. This stored declaration is ignored for startup validation; fix or delete the Manager from the Codespace settings page.", address.ManagerID, address.Address, err) + continue + } + warnGatewayCookieScopeConflict(address.ManagerID, normalized) + } + return nil +} + +func normalizeGatewayURL(rawURL string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return "", fmt.Errorf("parse gateway url: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", errors.New("gateway url must use http or https") + } + if setting.Codespace.GatewayRequireHTTPS && parsed.Scheme != "https" { + return "", errors.New("gateway url must use https") + } + host := strings.ToLower(parsed.Hostname()) + if host == "" { + return "", errors.New("gateway url host is required") + } + if err := validateDNSHost(host); err != nil { + return "", fmt.Errorf("invalid gateway url host: %w", err) + } + if len(strings.Repeat("a", 30)+"-"+strings.Repeat("0", 32)+"."+host) > 253 { + return "", errors.New("derived gateway endpoint host is too long") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("gateway url must not contain userinfo, query, or fragment") + } + if parsed.Path != "" && parsed.Path != "/" { + return "", errors.New("gateway url must not contain a business path") + } + port := parsed.Port() + if port != "" { + portNumber, err := strconv.Atoi(port) + if err != nil || portNumber < 1 || portNumber > 65535 { + return "", errors.New("invalid gateway url port") + } + if (parsed.Scheme == "http" && portNumber == 80) || (parsed.Scheme == "https" && portNumber == 443) { + port = "" + } else { + port = strconv.Itoa(portNumber) + } + } + normalized := parsed.Scheme + "://" + host + if port != "" { + normalized += ":" + port + } + if len(normalized) > 512 { + return "", errors.New("gateway url is too long") + } + return normalized, nil +} + +func warnGatewayCookieScopeConflict(managerID int64, gatewayURL string) { + reason := diagnoseGatewayCookieScope(gatewayURL) + if reason == "" { + return + } + log.Warn("Codespace Manager Gateway cookie scope overlaps Gitea login scope: manager_id=%d gateway_url=%q root_url=%q session_domain=%q reason=%s. This is allowed because deployment scope is an administrator choice; use a separate registrable domain for stricter browser cookie isolation.", + managerID, gatewayURL, setting.AppURL, setting.SessionConfig.Domain, reason) +} + +func diagnoseGatewayCookieScope(gatewayURL string) string { + parsed, err := url.Parse(gatewayURL) + if err != nil { + return "gateway URL cannot be parsed" + } + gatewayHost := parsed.Hostname() + + giteaURL, err := url.Parse(setting.AppURL) + if err != nil { + return "Gitea ROOT_URL cannot be parsed" + } + giteaHost := strings.ToLower(giteaURL.Hostname()) + if giteaHost != "" && net.ParseIP(giteaHost) == nil { + if err := validateDNSHost(giteaHost); err != nil { + return "Gitea ROOT_URL host is not a valid DNS host" + } + if sameRegistrableDomain(gatewayHost, giteaHost) { + return "Gateway host and Gitea ROOT_URL host share the same registrable domain" + } + if isSameOrSubdomain(giteaHost, gatewayHost) { + return "Gateway host covers the Gitea ROOT_URL host" + } + } + + sessionDomain := normalizeCookieDomain(setting.SessionConfig.Domain) + if sessionDomain == "" { + return "" + } + if err := validateDNSHost(sessionDomain); err != nil { + return "Gitea session cookie domain is not a valid DNS host" + } + if isSameOrSubdomain(gatewayHost, sessionDomain) { + return "Gateway host is inside the Gitea session cookie domain" + } + return "" +} + +func sameRegistrableDomain(a, b string) bool { + aSite, err := publicsuffix.EffectiveTLDPlusOne(a) + if err != nil { + return true + } + bSite, err := publicsuffix.EffectiveTLDPlusOne(b) + if err != nil { + return true + } + return aSite == bSite +} + +func normalizeCookieDomain(domain string) string { + domain = strings.ToLower(strings.TrimSpace(domain)) + domain = strings.TrimPrefix(domain, ".") + return strings.TrimSuffix(domain, ".") +} + +func isSameOrSubdomain(host, parent string) bool { + return host == parent || strings.HasSuffix(host, "."+parent) +} + +func normalizeGatewaySSHAddr(rawAddr string) (string, error) { + host, port, err := net.SplitHostPort(strings.TrimSpace(rawAddr)) + if err != nil { + return "", errors.New("gateway ssh address must use host:port") + } + host = strings.ToLower(strings.TrimSpace(host)) + if err := validateDNSHost(host); err != nil { + return "", fmt.Errorf("invalid gateway ssh host: %w", err) + } + portNumber, err := strconv.Atoi(port) + if err != nil || portNumber < 1 || portNumber > 65535 { + return "", errors.New("invalid gateway ssh port") + } + normalized := net.JoinHostPort(host, strconv.Itoa(portNumber)) + if len(normalized) > 512 { + return "", errors.New("gateway ssh address is too long") + } + return normalized, nil +} + +func validateDNSHost(host string) error { + if host == "" || strings.HasSuffix(host, ".") { + return errors.New("host must be a DNS name without trailing dot") + } + if net.ParseIP(host) != nil { + return errors.New("host must not be an IP address") + } + if len(host) > 253 { + return errors.New("host is too long") + } + for label := range strings.SplitSeq(host, ".") { + if !dnsLabelPattern.MatchString(label) { + return fmt.Errorf("invalid DNS label %q", label) + } + } + return nil +} diff --git a/services/codespace/manager_test.go b/services/codespace/manager_test.go new file mode 100644 index 0000000000000..d26bd02b4587d --- /dev/null +++ b/services/codespace/manager_test.go @@ -0,0 +1,301 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "strings" + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeDeclareManagerOptions(t *testing.T) { + mockGatewayScopeSettings(t, "http://127.0.0.1:3000/", "", false) + + opts := validDeclareManagerOptions() + opts.GatewayURL = "https://WorkSpace.EXAMPLE.com:443/" + opts.GatewaySSHAddr = "WorkSpace.EXAMPLE.com:0022" + opts.Name = " manager " + opts.Version = " 1.0.0 " + opts.GatewaySSHHostKeyAlgorithm = " ssh-ed25519 " + opts.GatewaySSHHostKeyFingerprintSHA256 = " SHA256:abc " + + normalized, err := normalizeDeclareManagerOptions(opts) + require.NoError(t, err) + assert.Equal(t, "https://workspace.example.com", normalized.GatewayURL) + assert.Equal(t, "workspace.example.com:22", normalized.GatewaySSHAddr) + assert.Equal(t, "manager", normalized.Name) + assert.Equal(t, "1.0.0", normalized.Version) + assert.Equal(t, "ssh-ed25519", normalized.GatewaySSHHostKeyAlgorithm) + assert.Equal(t, "SHA256:abc", normalized.GatewaySSHHostKeyFingerprintSHA256) +} + +func TestNormalizeManagerEnvironments(t *testing.T) { + environments, err := normalizeManagerEnvironments([]*codespacev1.EnvironmentTag{ + {Tag: " Standard ", Description: " General development "}, + {Tag: "gpu"}, + }) + require.NoError(t, err) + assert.Equal(t, []ManagerEnvironmentDeclaration{ + {Tag: "standard", Description: "General development"}, + {Tag: "gpu"}, + }, environments) + + _, err = normalizeManagerEnvironments([]*codespacev1.EnvironmentTag{{Tag: "standard"}, {Tag: "STANDARD"}}) + require.Error(t, err) +} + +func TestNormalizeDeclareManagerOptionsRejectsInvalidFields(t *testing.T) { + mockGatewayScopeSettings(t, "http://127.0.0.1:3000/", "", false) + + for _, tc := range []struct { + name string + mutate func(*DeclareManagerOptions) + }{ + { + name: "gateway url ip literal", + mutate: func(opts *DeclareManagerOptions) { + opts.GatewayURL = "https://127.0.0.1" + }, + }, + { + name: "gateway url trailing dot", + mutate: func(opts *DeclareManagerOptions) { + opts.GatewayURL = "https://workspace.example.com." + }, + }, + { + name: "ssh address missing port", + mutate: func(opts *DeclareManagerOptions) { + opts.GatewaySSHAddr = "workspace.example.com" + }, + }, + { + name: "invalid host key fingerprint", + mutate: func(opts *DeclareManagerOptions) { + opts.GatewaySSHHostKeyFingerprintSHA256 = "MD5:abc" + }, + }, + { + name: "version too long", + mutate: func(opts *DeclareManagerOptions) { + opts.Version = strings.Repeat("v", 65) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + opts := validDeclareManagerOptions() + tc.mutate(&opts) + + _, err := normalizeDeclareManagerOptions(opts) + require.Error(t, err) + }) + } +} + +func TestDiagnoseGatewayCookieScope(t *testing.T) { + for _, tc := range []struct { + name string + appURL string + sessionDomain string + gatewayURL string + expectConflict bool + }{ + { + name: "same registrable domain", + appURL: "https://gitea.example.com/", + gatewayURL: "https://workspace.example.com", + expectConflict: true, + }, + { + name: "gitea host under gateway domain", + appURL: "https://gitea.workspace.example.net/", + gatewayURL: "https://workspace.example.net", + expectConflict: true, + }, + { + name: "gitea ip literal", + appURL: "http://127.0.0.1:3000/", + gatewayURL: "https://workspace.example.com", + expectConflict: false, + }, + { + name: "session cookie domain", + appURL: "http://127.0.0.1:3000/", + sessionDomain: ".example.com", + gatewayURL: "https://workspace.example.com", + expectConflict: true, + }, + { + name: "separate registrable domains", + appURL: "https://gitea.example.org/", + gatewayURL: "https://workspace.example.com", + expectConflict: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + mockGatewayScopeSettings(t, tc.appURL, tc.sessionDomain, false) + opts := validDeclareManagerOptions() + opts.GatewayURL = tc.gatewayURL + + if tc.expectConflict { + assert.NotEmpty(t, diagnoseGatewayCookieScope(tc.gatewayURL)) + } else { + assert.Empty(t, diagnoseGatewayCookieScope(tc.gatewayURL)) + } + _, err := normalizeDeclareManagerOptions(opts) + require.NoError(t, err) + }) + } +} + +func TestNormalizeDeclareManagerOptionsGatewayRequiresHTTPS(t *testing.T) { + mockGatewayScopeSettings(t, "http://127.0.0.1:3000/", "", true) + opts := validDeclareManagerOptions() + opts.GatewayURL = "http://workspace.example.com" + + _, err := normalizeDeclareManagerOptions(opts) + require.ErrorContains(t, err, "gateway url must use https") +} + +func TestWarnManagerGatewayAddressConflicts(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + mockGatewayScopeSettings(t, "https://gitea.example.org/", "", false) + t.Cleanup(test.MockVariableValue(&setting.Codespace.Enabled, true)) + + manager := insertServiceManager(t) + insertServiceManagerGatewayAddress(t, manager, "https://workspace.example.com") + invalidManager := insertServiceManager(t) + require.NoError(t, db.Insert(t.Context(), &codespace_model.ManagerAddress{ + ManagerID: invalidManager.ID, + Kind: codespace_model.ManagerAddressGateway, + Address: "http://127.0.0.1:18081", + })) + + require.NoError(t, WarnManagerGatewayAddressConflicts(t.Context())) + + t.Cleanup(test.MockVariableValue(&setting.AppURL, "https://gitea.example.com/")) + require.NoError(t, WarnManagerGatewayAddressConflicts(t.Context())) +} + +func TestCodespaceInitSkipsGatewayAddressValidationWhenDisabled(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + mockGatewayScopeSettings(t, "https://gitea.example.com/", "", false) + t.Cleanup(test.MockVariableValue(&setting.Codespace.Enabled, false)) + + manager := insertServiceManager(t) + insertServiceManagerGatewayAddress(t, manager, "https://workspace.example.com") + + require.NoError(t, Init(t.Context())) +} + +func TestCodespaceInitAllowsHTTPWhenSSHCloneDisabled(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + mockGatewayScopeSettings(t, "https://gitea.example.com/", "", false) + t.Cleanup(test.MockVariableValue(&setting.Codespace.Enabled, true)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GitProtocol, codespace_model.GitProtocolHTTP)) + t.Cleanup(test.MockVariableValue(&setting.Repository.DisableHTTPGit, false)) + t.Cleanup(test.MockVariableValue(&setting.SSH.Disabled, true)) + t.Cleanup(test.MockVariableValue(&setting.SSH.StartBuiltinServer, false)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GitSSHKnownHosts, []string(nil))) + + require.NoError(t, Init(t.Context())) +} + +func TestCodespaceInitRequiresSSHForSSHPreferred(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + mockGatewayScopeSettings(t, "https://gitea.example.com/", "", false) + t.Cleanup(test.MockVariableValue(&setting.Codespace.Enabled, true)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GitProtocol, codespace_model.GitProtocolSSH)) + t.Cleanup(test.MockVariableValue(&setting.Repository.DisableHTTPGit, false)) + t.Cleanup(test.MockVariableValue(&setting.SSH.Disabled, true)) + t.Cleanup(test.MockVariableValue(&setting.SSH.StartBuiltinServer, false)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GitSSHKnownHosts, []string(nil))) + + err := Init(t.Context()) + require.Error(t, err) + assert.ErrorContains(t, err, "[server] DISABLE_SSH=true") +} + +func TestValidateCodespaceConfigAndTimings(t *testing.T) { + t.Cleanup(test.MockVariableValue(&setting.Codespace.ControlPlaneTimeout, 10*time.Second)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.ControlPlaneMaxSize, int64(32*1024*1024))) + t.Cleanup(test.MockVariableValue(&setting.Codespace.ManagerOfflineTimeout, 80*time.Second)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.OperationLeaseTimeout, 1500*time.Millisecond)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.OperationMaxDuration, 3*time.Hour)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.QueueTimeout, 7*time.Minute)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.OpenTokenExpire, 45*time.Second)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.LogMaxSize, int64(32*1024*1024))) + t.Cleanup(test.MockVariableValue(&setting.Codespace.RuntimeMetadataMaxSize, int64(128*1024))) + t.Cleanup(test.MockVariableValue(&setting.Codespace.DevContainerConfigMaxSize, int64(96*1024))) + t.Cleanup(test.MockVariableValue(&setting.Codespace.DevContainerDefaultImage, "registry.example.com/devcontainer:latest")) + t.Cleanup(test.MockVariableValue(&setting.Codespace.AutoStopDefaultTimeout, 25*time.Minute)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.AutoStopMinTimeout, 3*time.Minute)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.AutoStopMaxTimeout, 24*time.Hour)) + + require.NoError(t, ValidateCodespaceConfig()) + heartbeatMillis, metadataRefreshMillis, maxMessageBytes, _ := ManagerServiceTimings() + assert.EqualValues(t, 20_000, heartbeatMillis) + assert.EqualValues(t, 40_000, metadataRefreshMillis) + assert.EqualValues(t, 32*1024*1024, maxMessageBytes) + + minControlPlaneSize, minControlPlaneMessage := minimumControlPlaneMaxMessageSize() + restoreControlPlaneMaxSize := test.MockVariableValue(&setting.Codespace.ControlPlaneMaxSize, minControlPlaneSize-1) + err := ValidateCodespaceConfig() + require.Error(t, err) + assert.ErrorContains(t, err, "CONTROL_PLANE_MAX_MESSAGE_SIZE") + assert.ErrorContains(t, err, minControlPlaneMessage) + restoreControlPlaneMaxSize() + + t.Cleanup(test.MockVariableValue(&setting.Codespace.ControlPlaneTimeout, 21*time.Second)) + require.ErrorContains(t, ValidateCodespaceConfig(), "CONTROL_PLANE_TIMEOUT") +} + +func TestDeclareManagerRejectsDeletedManager(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + require.NoError(t, DeclareManager(t.Context(), manager, validDeclareManagerOptions())) + assertServiceExists(t, new(codespace_model.ManagerAddress), "manager_id = ?", manager.ID) + + require.NoError(t, DeleteManager(t.Context(), DeleteManagerOptions{ + Scope: ManagerSettingsScopeSite, + ManagerID: manager.ID, + Confirm: true, + })) + + err := DeclareManager(t.Context(), manager, validDeclareManagerOptions()) + require.ErrorIs(t, err, ErrManagerUnregistered) + assertServiceNotExists(t, new(codespace_model.ManagerAddress), "manager_id = ?", manager.ID) +} + +func validDeclareManagerOptions() DeclareManagerOptions { + return DeclareManagerOptions{ + GatewayURL: "https://workspace.example.com", + GatewaySSHAddr: "workspace.example.com:22", + Environments: []*codespacev1.EnvironmentTag{{Tag: "default"}}, + Version: "1.0.0", + Name: "manager", + RuntimeState: codespacev1.ManagerRuntimeState_MANAGER_RUNTIME_STATE_ONLINE, + GatewaySSHHostKeyAlgorithm: "ssh-ed25519", + GatewaySSHHostKeyFingerprintSHA256: "SHA256:abc", + GatewaySSHHostKeyUpdatedUnix: 1, + } +} + +func mockGatewayScopeSettings(t *testing.T, appURL, sessionDomain string, requireHTTPS bool) { + t.Helper() + t.Cleanup(test.MockVariableValue(&setting.AppURL, appURL)) + t.Cleanup(test.MockVariableValue(&setting.SessionConfig.Domain, sessionDomain)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.GatewayRequireHTTPS, requireHTTPS)) +} diff --git a/services/codespace/metadata.go b/services/codespace/metadata.go new file mode 100644 index 0000000000000..18f527ae57790 --- /dev/null +++ b/services/codespace/metadata.go @@ -0,0 +1,504 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "math" + "regexp" + "slices" + "strings" + "time" + "unicode" + "unicode/utf8" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/modules/cache" + "gitea.dev/modules/globallock" + "gitea.dev/modules/json" + "gitea.dev/modules/setting" + + "google.golang.org/protobuf/proto" +) + +const ( + workspaceEndpointID = "workspace" + workspaceEndpointLabel = "Workspace" + bootStagePrepareRuntime = "prepare-runtime" + bootStageInitializeSystem = "initialize-system" + bootStagePrepareWorkspace = "prepare-workspace" + bootStageStartEnvironment = "start-environment" + bootStagePublishReady = "publish-ready" + bootStageReady = "ready" +) + +var endpointIDPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$`) + +var ( + // ErrRuntimeMetadataManagerMismatch is returned when the Codespace is bound to another Manager. + ErrRuntimeMetadataManagerMismatch = errors.New("codespace belongs to another manager") + // ErrRuntimeMetadataStaleOperation is returned when the snapshot no longer matches current lifecycle state. + ErrRuntimeMetadataStaleOperation = errors.New("runtime metadata does not match current operation") + // ErrRuntimeMetadataGenerationConflict is returned when one generation carries different content. + ErrRuntimeMetadataGenerationConflict = errors.New("runtime metadata generation conflict") + // ErrRuntimeMetadataVersionExhausted is returned when Gitea cannot provide a higher metadata generation. + ErrRuntimeMetadataVersionExhausted = errors.New("runtime metadata generation exhausted") + // ErrRuntimeMetadataManagerOffline is returned when the authenticated Manager is not usable for metadata writes. + ErrRuntimeMetadataManagerOffline = errors.New("manager is not online") + // ErrRuntimeMetadataStateUnavailable is returned when Codespace metadata writes are disabled. + ErrRuntimeMetadataStateUnavailable = errors.New("codespace runtime metadata state unavailable") +) + +// StaleGenerationError reports the server-side generation that supersedes a request. +type StaleGenerationError struct { + CurrentGeneration int64 +} + +func (e *StaleGenerationError) Error() string { + return fmt.Sprintf("runtime metadata generation is stale; current generation is %d", e.CurrentGeneration) +} + +// ReportRuntimeMetadataOptions contains a Manager metadata report after RPC validation. +type ReportRuntimeMetadataOptions struct { + CodespaceUUID string + Metadata *codespacev1.RuntimeMetadata + MetadataGeneration int64 +} + +type runtimeMetadataCacheEntry struct { + Metadata runtimeMetadata `json:"metadata"` + Generation int64 `json:"generation"` + ContentHash string `json:"content_hash"` + LastReportedUnix int64 `json:"last_reported_unix"` +} + +type runtimeMetadata struct { + Endpoints []runtimeMetadataEndpoint `json:"endpoints"` + Boot runtimeMetadataBoot `json:"boot"` + ResourceUsage runtimeMetadataResourceUsage `json:"resource_usage"` +} + +type runtimeMetadataEndpoint struct { + EndpointID string `json:"endpoint_id"` + Label string `json:"label"` + Public bool `json:"public"` +} + +func (m runtimeMetadata) endpointByID(endpointID string) (runtimeMetadataEndpoint, bool) { + for _, endpoint := range m.Endpoints { + if endpoint.EndpointID == endpointID { + return endpoint, true + } + } + return runtimeMetadataEndpoint{}, false +} + +func runtimeMetadataReadyForRunning(codespace *codespace_model.Codespace, metadata runtimeMetadata) bool { + return metadata.Boot.Stage == bootStageReady && + metadata.Boot.OperationRVersion <= codespace.OperationRVersion +} + +type runtimeMetadataBoot struct { + OperationRVersion int64 `json:"operation_rversion"` + Stage string `json:"stage"` + StartedUnix int64 `json:"started_unix"` + LastUpdateUnix int64 `json:"last_update_unix"` +} + +type runtimeMetadataResourceUsage struct { + CPU runtimeMetadataCPUUsage `json:"cpu"` + Memory runtimeMetadataMemoryUsage `json:"memory"` + Disk runtimeMetadataDiskUsage `json:"disk"` + ObservedUnix int64 `json:"observed_unix"` +} + +type runtimeMetadataCPUUsage struct { + UsedMillicores int64 `json:"used_millicores"` + LimitMillicores int64 `json:"limit_millicores"` +} + +type runtimeMetadataMemoryUsage struct { + UsedBytes int64 `json:"used_bytes"` + LimitBytes int64 `json:"limit_bytes"` +} + +type runtimeMetadataDiskUsage struct { + UsedBytes int64 `json:"used_bytes"` + LimitBytes int64 `json:"limit_bytes"` +} + +// ReportRuntimeMetadata validates and stores a Runtime Metadata snapshot in Gitea cache. +func ReportRuntimeMetadata(ctx context.Context, manager *codespace_model.Manager, opts ReportRuntimeMetadataOptions) error { + if !setting.Codespace.Enabled { + return ErrRuntimeMetadataStateUnavailable + } + if manager == nil || manager.ID <= 0 { + return errors.New("manager is required") + } + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return err + } + if opts.MetadataGeneration <= 0 { + return errors.New("metadata_generation must be positive") + } + allowed, err := currentManagerAllowsOnlineOrRecovering(ctx, manager.ID) + if err != nil { + return err + } + if !allowed { + return ErrRuntimeMetadataManagerOffline + } + metadata, contentHash, err := normalizeRuntimeMetadata(opts.Metadata) + if err != nil { + return err + } + + return globallock.LockAndDo(ctx, runtimeMetadataLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + allowed, err = currentManagerAllowsOnlineOrRecovering(ctx, manager.ID) + if err != nil { + return err + } + if !allowed { + return ErrRuntimeMetadataManagerOffline + } + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceUUID).Get(codespace) + if err != nil { + return err + } + if !has { + return ErrRuntimeMetadataStaleOperation + } + if codespace.ManagerID != manager.ID { + return ErrRuntimeMetadataManagerMismatch + } + if err := validateRuntimeMetadataState(codespace, metadata); err != nil { + return err + } + + current, hasCurrent, err := getRuntimeMetadataEntry(opts.CodespaceUUID) + if err != nil { + return err + } + if hasCurrent { + if opts.MetadataGeneration < current.Generation { + if current.Generation == math.MaxInt64 { + return ErrRuntimeMetadataVersionExhausted + } + return &StaleGenerationError{CurrentGeneration: current.Generation} + } + if opts.MetadataGeneration == current.Generation && contentHash != current.ContentHash { + return ErrRuntimeMetadataGenerationConflict + } + if err := validateRuntimeMetadataStageForward(current.Metadata, metadata); err != nil { + return err + } + } + + return putRuntimeMetadataEntry(opts.CodespaceUUID, runtimeMetadataCacheEntry{ + Metadata: metadata, + Generation: opts.MetadataGeneration, + ContentHash: contentHash, + LastReportedUnix: time.Now().Unix(), + }) + }) +} + +// HasReadyRuntimeMetadata reports whether Gitea cache contains current operation ready metadata. +func HasReadyRuntimeMetadata(_ context.Context, codespaceUUID string, operationRVersion int64) (bool, error) { + entry, has, err := getRuntimeMetadataEntry(codespaceUUID) + if err != nil || !has { + return false, err + } + return entry.Metadata.Boot.Stage == bootStageReady && + entry.Metadata.Boot.OperationRVersion == operationRVersion, nil +} + +func deleteRuntimeMetadata(codespaceUUID string) { + if cache.GetCache() == nil { + return + } + _ = cache.GetCache().Delete(runtimeMetadataCacheKey(codespaceUUID)) +} + +func normalizeRuntimeMetadata(input *codespacev1.RuntimeMetadata) (runtimeMetadata, string, error) { + if input == nil { + return runtimeMetadata{}, "", errors.New("runtime metadata is required") + } + if int64(proto.Size(input)) > setting.Codespace.RuntimeMetadataMaxSize { + return runtimeMetadata{}, "", errors.New("runtime metadata exceeds maximum size") + } + if input.GetBoot() == nil { + return runtimeMetadata{}, "", errors.New("runtime metadata boot is required") + } + resourceUsage, err := normalizeRuntimeMetadataResourceUsage(input.GetResourceUsage()) + if err != nil { + return runtimeMetadata{}, "", err + } + bootStage, ok := runtimeMetadataBootStage(input.GetBoot().GetStage()) + if !ok { + return runtimeMetadata{}, "", fmt.Errorf("invalid boot stage %q", input.GetBoot().GetStage()) + } + + metadata := runtimeMetadata{ + Endpoints: make([]runtimeMetadataEndpoint, 0, len(input.GetEndpoints())), + Boot: runtimeMetadataBoot{ + OperationRVersion: input.GetBoot().GetOperationRversion(), + Stage: bootStage, + StartedUnix: input.GetBoot().GetStartedUnix(), + LastUpdateUnix: input.GetBoot().GetLastUpdateUnix(), + }, + ResourceUsage: resourceUsage, + } + if err := validateRuntimeMetadataBoot(metadata.Boot); err != nil { + return runtimeMetadata{}, "", err + } + if len(input.GetEndpoints()) > 64 { + return runtimeMetadata{}, "", errors.New("runtime metadata endpoints exceed 64") + } + seen := make(map[string]struct{}, len(input.GetEndpoints())) + workspaceFound := false + for _, endpoint := range input.GetEndpoints() { + normalized, err := normalizeRuntimeMetadataEndpoint(endpoint) + if err != nil { + return runtimeMetadata{}, "", err + } + if _, ok := seen[normalized.EndpointID]; ok { + return runtimeMetadata{}, "", fmt.Errorf("duplicate endpoint_id %q", normalized.EndpointID) + } + seen[normalized.EndpointID] = struct{}{} + if normalized.EndpointID == workspaceEndpointID { + workspaceFound = true + } + metadata.Endpoints = append(metadata.Endpoints, normalized) + } + if (bootStage == bootStagePublishReady || bootStage == bootStageReady) && !workspaceFound { + return runtimeMetadata{}, "", errors.New("runtime metadata workspace endpoint is required") + } + slices.SortFunc(metadata.Endpoints, func(a, b runtimeMetadataEndpoint) int { + return strings.Compare(a.EndpointID, b.EndpointID) + }) + + canonical, err := json.Marshal(metadata) + if err != nil { + return runtimeMetadata{}, "", fmt.Errorf("encode canonical runtime metadata: %w", err) + } + if int64(len(canonical)) > setting.Codespace.RuntimeMetadataMaxSize { + return runtimeMetadata{}, "", errors.New("runtime metadata exceeds maximum size") + } + sum := sha256.Sum256(canonical) + return metadata, hex.EncodeToString(sum[:]), nil +} + +func normalizeRuntimeMetadataEndpoint(endpoint *codespacev1.RuntimeEndpoint) (runtimeMetadataEndpoint, error) { + if endpoint == nil { + return runtimeMetadataEndpoint{}, errors.New("runtime metadata endpoint is required") + } + if !endpointIDPattern.MatchString(endpoint.GetEndpointId()) { + return runtimeMetadataEndpoint{}, fmt.Errorf("invalid endpoint_id %q", endpoint.GetEndpointId()) + } + label, err := normalizeRuntimeMetadataLabel(endpoint.GetLabel()) + if err != nil { + return runtimeMetadataEndpoint{}, err + } + normalized := runtimeMetadataEndpoint{ + EndpointID: endpoint.GetEndpointId(), + Label: label, + Public: endpoint.GetPublic(), + } + if normalized.EndpointID == workspaceEndpointID && (normalized.Label != workspaceEndpointLabel || normalized.Public) { + return runtimeMetadataEndpoint{}, errors.New("runtime metadata workspace endpoint is invalid") + } + return normalized, nil +} + +func normalizeRuntimeMetadataResourceUsage(input *codespacev1.RuntimeResourceUsage) (runtimeMetadataResourceUsage, error) { + if input == nil { + return runtimeMetadataResourceUsage{}, errors.New("runtime metadata resource_usage is required") + } + if input.GetCpu() == nil { + return runtimeMetadataResourceUsage{}, errors.New("runtime metadata cpu usage is required") + } + if input.GetMemory() == nil { + return runtimeMetadataResourceUsage{}, errors.New("runtime metadata memory usage is required") + } + if input.GetDisk() == nil { + return runtimeMetadataResourceUsage{}, errors.New("runtime metadata disk usage is required") + } + usage := runtimeMetadataResourceUsage{ + CPU: runtimeMetadataCPUUsage{ + UsedMillicores: input.GetCpu().GetUsedMillicores(), + LimitMillicores: input.GetCpu().GetLimitMillicores(), + }, + Memory: runtimeMetadataMemoryUsage{ + UsedBytes: input.GetMemory().GetUsedBytes(), + LimitBytes: input.GetMemory().GetLimitBytes(), + }, + Disk: runtimeMetadataDiskUsage{ + UsedBytes: input.GetDisk().GetUsedBytes(), + LimitBytes: input.GetDisk().GetLimitBytes(), + }, + ObservedUnix: input.GetObservedUnix(), + } + if usage.CPU.UsedMillicores < 0 || usage.CPU.LimitMillicores < 0 || + usage.Memory.UsedBytes < 0 || usage.Memory.LimitBytes < 0 || + usage.Disk.UsedBytes < 0 || usage.Disk.LimitBytes < 0 || + usage.ObservedUnix < 0 { + return runtimeMetadataResourceUsage{}, errors.New("runtime metadata resource usage must not be negative") + } + return usage, nil +} + +func runtimeMetadataBootStage(stage codespacev1.RuntimeBootStage) (string, bool) { + switch stage { + case codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_PREPARE_RUNTIME: + return bootStagePrepareRuntime, true + case codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_INITIALIZE_SYSTEM: + return bootStageInitializeSystem, true + case codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_PREPARE_WORKSPACE: + return bootStagePrepareWorkspace, true + case codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_START_ENVIRONMENT: + return bootStageStartEnvironment, true + case codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_PUBLISH_READY: + return bootStagePublishReady, true + case codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_READY: + return bootStageReady, true + default: + return "", false + } +} + +func normalizeRuntimeMetadataLabel(label string) (string, error) { + if !utf8.ValidString(label) { + return "", errors.New("endpoint label must be valid UTF-8") + } + label = strings.TrimSpace(label) + count := utf8.RuneCountInString(label) + if count < 1 || count > 64 { + return "", errors.New("endpoint label must be 1 to 64 characters") + } + for _, r := range label { + if unicode.IsControl(r) || r == '<' || r == '>' { + return "", errors.New("endpoint label contains invalid character") + } + } + return label, nil +} + +func validateRuntimeMetadataBoot(boot runtimeMetadataBoot) error { + if boot.OperationRVersion <= 0 { + return errors.New("boot operation_rversion must be positive") + } + if bootStageRank(boot.Stage) < 0 { + return fmt.Errorf("invalid boot stage %q", boot.Stage) + } + if boot.StartedUnix <= 0 { + return errors.New("boot started_unix must be positive") + } + if boot.LastUpdateUnix < boot.StartedUnix { + return errors.New("boot last_update_unix must not precede started_unix") + } + return nil +} + +func validateRuntimeMetadataState(codespace *codespace_model.Codespace, metadata runtimeMetadata) error { + switch codespace.Status { + case codespace_model.StatusCreating: + if !currentOperationMatches(codespace, codespace_model.OperationCreate, metadata.Boot.OperationRVersion) { + return ErrRuntimeMetadataStaleOperation + } + case codespace_model.StatusStopped: + if !currentOperationMatches(codespace, codespace_model.OperationResume, metadata.Boot.OperationRVersion) { + return ErrRuntimeMetadataStaleOperation + } + case codespace_model.StatusRunning: + if metadata.Boot.Stage != bootStageReady || metadata.Boot.OperationRVersion > codespace.OperationRVersion { + return ErrRuntimeMetadataStaleOperation + } + default: + return ErrRuntimeMetadataStaleOperation + } + return nil +} + +func validateRuntimeMetadataStageForward(current, next runtimeMetadata) error { + if current.Boot.OperationRVersion != next.Boot.OperationRVersion { + return nil + } + if bootStageRank(next.Boot.Stage) < bootStageRank(current.Boot.Stage) { + return ErrRuntimeMetadataStaleOperation + } + return nil +} + +func currentOperationMatches(codespace *codespace_model.Codespace, operationType string, operationRVersion int64) bool { + return codespace.OperationRVersion == operationRVersion && + codespace.OperationType == operationType && + codespace.OperationStatus == codespace_model.OperationStatusRunning +} + +func createOrResumeOperationActive(codespace *codespace_model.Codespace, now int64) bool { + switch codespace.Status { + case codespace_model.StatusCreating: + return currentOperationMatches(codespace, codespace_model.OperationCreate, codespace.OperationRVersion) && + codespace.OperationDeadlineUnix > now + case codespace_model.StatusStopped: + return currentOperationMatches(codespace, codespace_model.OperationResume, codespace.OperationRVersion) && + codespace.OperationDeadlineUnix > now + default: + return false + } +} + +func bootStageRank(stage string) int { + switch stage { + case bootStagePrepareRuntime: + return 0 + case bootStageInitializeSystem: + return 1 + case bootStagePrepareWorkspace: + return 2 + case bootStageStartEnvironment: + return 3 + case bootStagePublishReady: + return 4 + case bootStageReady: + return 5 + default: + return -1 + } +} + +func getRuntimeMetadataEntry(codespaceUUID string) (runtimeMetadataCacheEntry, bool, error) { + if cache.GetCache() == nil { + return runtimeMetadataCacheEntry{}, false, errors.New("cache is not initialized") + } + entry := runtimeMetadataCacheEntry{} + exists, getErr := cache.GetCache().GetJSON(runtimeMetadataCacheKey(codespaceUUID), &entry) + if getErr != nil { + return runtimeMetadataCacheEntry{}, false, getErr.ToError() + } + return entry, exists, nil +} + +func putRuntimeMetadataEntry(codespaceUUID string, entry runtimeMetadataCacheEntry) error { + if cache.GetCache() == nil { + return errors.New("cache is not initialized") + } + return cache.GetCache().PutJSON(runtimeMetadataCacheKey(codespaceUUID), entry, int64((setting.Codespace.ManagerOfflineTimeout*2)/time.Second)) +} + +func runtimeMetadataCacheKey(codespaceUUID string) string { + return "codespace:runtime-meta:" + codespaceUUID +} + +func runtimeMetadataLockKey(codespaceUUID string) string { + return "codespace_runtime_metadata_" + codespaceUUID +} diff --git a/services/codespace/metadata_test.go b/services/codespace/metadata_test.go new file mode 100644 index 0000000000000..5c04a389c82ea --- /dev/null +++ b/services/codespace/metadata_test.go @@ -0,0 +1,181 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "strings" + "testing" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeRuntimeMetadataProtoValidation(t *testing.T) { + tests := []struct { + name string + mutate func(*codespacev1.RuntimeMetadata) + }{ + { + name: "missing boot", + mutate: func(metadata *codespacev1.RuntimeMetadata) { + metadata.Boot = nil + }, + }, + { + name: "invalid stage", + mutate: func(metadata *codespacev1.RuntimeMetadata) { + metadata.Boot.Stage = codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_UNSPECIFIED + }, + }, + { + name: "duplicate workspace endpoint", + mutate: func(metadata *codespacev1.RuntimeMetadata) { + metadata.Endpoints[0].EndpointId = "workspace" + }, + }, + { + name: "missing workspace endpoint", + mutate: func(metadata *codespacev1.RuntimeMetadata) { + metadata.Endpoints = metadata.Endpoints[:1] + }, + }, + { + name: "public workspace endpoint", + mutate: func(metadata *codespacev1.RuntimeMetadata) { + metadata.Endpoints[len(metadata.Endpoints)-1].Public = true + }, + }, + { + name: "duplicate endpoint id", + mutate: func(metadata *codespacev1.RuntimeMetadata) { + metadata.Endpoints = append(metadata.Endpoints, &codespacev1.RuntimeEndpoint{ + EndpointId: "web", + Label: "Web Copy", + }) + }, + }, + { + name: "invalid endpoint id", + mutate: func(metadata *codespacev1.RuntimeMetadata) { + metadata.Endpoints[0].EndpointId = "Workspace" + }, + }, + { + name: "missing resource usage", + mutate: func(metadata *codespacev1.RuntimeMetadata) { + metadata.ResourceUsage = nil + }, + }, + { + name: "negative resource usage", + mutate: func(metadata *codespacev1.RuntimeMetadata) { + metadata.ResourceUsage.Memory.UsedBytes = -1 + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + metadata := metadataProtoForTest(t, 1, bootStageReady, []map[string]any{{ + "endpoint_id": "web", + "label": "Web", + "public": false, + }}) + tc.mutate(metadata) + _, _, err := normalizeRuntimeMetadata(metadata) + require.Error(t, err) + }) + } +} + +func TestNormalizeRuntimeMetadataLabelBoundaries(t *testing.T) { + valid64 := strings.Repeat("็•Œ", 64) + tests := []struct { + name string + label string + wantErr bool + normalized string + secondLabelOK bool + }{ + {name: "trim unicode whitespace", label: " \t็•Œ้ข\n", normalized: "็•Œ้ข"}, + {name: "one character", label: "็•Œ", normalized: "็•Œ"}, + {name: "sixty four characters", label: valid64, normalized: valid64}, + {name: "sixty five characters", label: strings.Repeat("็•Œ", 65), wantErr: true}, + {name: "blank after trim", label: " \t\n", wantErr: true}, + {name: "control character", label: "bad\nlabel", wantErr: true}, + {name: "less-than", label: "badlabel", wantErr: true}, + {name: "duplicate label allowed", label: "ๆœๅŠก", normalized: "ๆœๅŠก", secondLabelOK: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + endpoints := []map[string]any{{ + "endpoint_id": "web", + "label": tc.label, + "public": false, + }} + if tc.secondLabelOK { + endpoints = append(endpoints, map[string]any{ + "endpoint_id": "app", + "label": tc.label, + "public": true, + }) + } + metadata, _, err := normalizeRuntimeMetadata(metadataProtoForTest(t, 1, bootStageReady, endpoints)) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + web, found := metadata.endpointByID("web") + require.True(t, found) + assert.Equal(t, tc.normalized, web.Label) + if tc.secondLabelOK { + require.Len(t, metadata.Endpoints, 3) + app, found := metadata.endpointByID("app") + require.True(t, found) + assert.Equal(t, web.Label, app.Label) + } + }) + } +} + +func TestNormalizeRuntimeMetadataCanonicalizesEndpointOrder(t *testing.T) { + first, firstHash, err := normalizeRuntimeMetadata(metadataProtoForTest(t, 1, bootStageReady, []map[string]any{ + {"endpoint_id": "z-api", "label": "Z API", "public": true}, + {"endpoint_id": "app", "label": "App", "public": true}, + })) + require.NoError(t, err) + + second, secondHash, err := normalizeRuntimeMetadata(metadataProtoForTest(t, 1, bootStageReady, []map[string]any{ + {"endpoint_id": "app", "label": "App", "public": true}, + {"endpoint_id": "z-api", "label": "Z API", "public": true}, + })) + require.NoError(t, err) + + assert.Equal(t, []string{"app", "workspace", "z-api"}, []string{ + first.Endpoints[0].EndpointID, + first.Endpoints[1].EndpointID, + first.Endpoints[2].EndpointID, + }) + assert.Equal(t, first, second) + assert.Equal(t, firstHash, secondHash) +} + +func TestNormalizeRuntimeMetadataBootAndResourceUsage(t *testing.T) { + metadata, _, err := normalizeRuntimeMetadata(metadataProtoForTest(t, 1, bootStagePublishReady, []map[string]any{})) + require.NoError(t, err) + assert.Equal(t, bootStagePublishReady, metadata.Boot.Stage) + assert.EqualValues(t, 125, metadata.ResourceUsage.CPU.UsedMillicores) + assert.EqualValues(t, 256*1024*1024, metadata.ResourceUsage.Memory.UsedBytes) + assert.EqualValues(t, 512*1024*1024, metadata.ResourceUsage.Disk.UsedBytes) +} + +func metadataProtoForTest(t *testing.T, operationRVersion int64, stage string, endpoints []map[string]any) *codespacev1.RuntimeMetadata { + t.Helper() + return serviceRuntimeMetadataProto(t, operationRVersion, stage, endpoints) +} diff --git a/services/codespace/open_token.go b/services/codespace/open_token.go new file mode 100644 index 0000000000000..cba67ac7523a9 --- /dev/null +++ b/services/codespace/open_token.go @@ -0,0 +1,557 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/url" + "strings" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + user_model "gitea.dev/models/user" + "gitea.dev/modules/cache" + "gitea.dev/modules/globallock" + "gitea.dev/modules/setting" + "gitea.dev/modules/util" +) + +var ( + errOpenTokenLoginRestricted = errors.New("codespace user login restricted") + + // ErrOpenEndpointNotFound is returned when the Codespace is not visible to the requesting user. + ErrOpenEndpointNotFound = errors.New("codespace not found") + // ErrOpenEndpointUnavailable is returned when the current lifecycle cannot open an endpoint. + ErrOpenEndpointUnavailable = errors.New("codespace endpoint is not currently available") +) + +const ( + // OpenTokenDeniedInvalidCredentials means the submitted open code is invalid. + OpenTokenDeniedInvalidCredentials = "invalid_credentials" + // OpenTokenDeniedLoginRestricted means the Codespace creator cannot currently log in. + OpenTokenDeniedLoginRestricted = "login_restricted" + // OpenTokenDeniedCodespaceNotFound means the Codespace no longer exists. + OpenTokenDeniedCodespaceNotFound = "codespace_not_found" + // OpenTokenDeniedCodespaceNotRunning means the Codespace is not running. + OpenTokenDeniedCodespaceNotRunning = "codespace_not_running" + // OpenTokenDeniedManagerMismatch means the code or Codespace is bound to another Manager. + OpenTokenDeniedManagerMismatch = "manager_mismatch" + // OpenTokenDeniedPermissionDenied means the code no longer matches the Codespace creator. + OpenTokenDeniedPermissionDenied = "permission_denied" + // OpenTokenDeniedStateUnavailable means the lifecycle state cannot accept the open request. + OpenTokenDeniedStateUnavailable = "state_unavailable" + // OpenTokenDeniedMetadataRebuilding means Runtime Metadata is absent or not ready. + OpenTokenDeniedMetadataRebuilding = "metadata_rebuilding" + // OpenTokenDeniedEndpointNotFound means the authenticated Endpoint binding is no longer private. + OpenTokenDeniedEndpointNotFound = "endpoint_not_found" + // OpenTokenDeniedVersionExhausted means interaction_generation cannot advance. + OpenTokenDeniedVersionExhausted = "version_exhausted" +) + +// OpenEndpointOptions identifies one authenticated Gitea Web open request. +type OpenEndpointOptions struct { + UserID int64 + CodespaceUUID string + EndpointID string +} + +// OpenEndpointResult contains the redirect target produced for one Web open request. +type OpenEndpointResult struct { + RedirectURL string + Public bool + InteractionGeneration int64 +} + +type openEndpointResult struct { + redirectURL string + public bool + interactionGeneration int64 + code string + managerID int64 +} + +type openEndpointTarget struct { + redirectURL string + public bool + available bool + unavailableCategory string +} + +// OpenEndpoint redirects public Endpoints directly and private targets through Open Token. +func OpenEndpoint(ctx context.Context, opts OpenEndpointOptions) (*OpenEndpointResult, error) { + opened, err := openEndpoint(ctx, opts) + if err != nil { + return nil, err + } + return &OpenEndpointResult{ + RedirectURL: opened.redirectURL, + Public: opened.public, + InteractionGeneration: opened.interactionGeneration, + }, nil +} + +func openEndpoint(ctx context.Context, opts OpenEndpointOptions) (*openEndpointResult, error) { + if opts.UserID <= 0 { + return nil, errors.New("user_id must be positive") + } + if err := validateOpenEndpointID(opts.EndpointID); err != nil { + return nil, err + } + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return nil, err + } + if !setting.Codespace.Enabled { + return nil, fmt.Errorf("%w: %s", ErrOpenEndpointUnavailable, OpenTokenDeniedStateUnavailable) + } + + var result *openEndpointResult + var unavailableCategory string + var tokenCacheKey string + err := globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceUUID).Get(codespace) + if err != nil { + return err + } + if !has { + return ErrOpenEndpointNotFound + } + if codespace.UserID != opts.UserID { + return ErrOpenEndpointNotFound + } + if codespace.Status != codespace_model.StatusRunning { + unavailableCategory = OpenTokenDeniedCodespaceNotRunning + return nil + } + manager, err := loadCodespaceManager(ctx, codespace.ManagerID) + if err != nil { + return err + } + if manager.RuntimeState != codespace_model.ManagerRuntimeStateOnline || isManagerOffline(manager) { + unavailableCategory = OpenTokenDeniedStateUnavailable + return nil + } + gatewayURL, err := loadManagerGatewayURL(ctx, codespace.ManagerID) + if err != nil { + return err + } + if err := checkCodespaceCreatorForOpen(ctx, codespace, opts.UserID); err != nil { + return err + } + entry, hasEntry, err := getRuntimeMetadataEntry(opts.CodespaceUUID) + if err != nil { + return err + } + if !hasEntry || !runtimeMetadataReadyForRunning(codespace, entry.Metadata) { + unavailableCategory = OpenTokenDeniedMetadataRebuilding + return nil + } + target, err := openEndpointInfo(codespace, entry.Metadata, gatewayURL, opts) + if err != nil { + return err + } + if !target.available { + unavailableCategory = target.unavailableCategory + return nil + } + if target.public { + result = &openEndpointResult{redirectURL: target.redirectURL, public: true} + return nil + } + + code := generateOpenTokenCode() + tokenCacheKey = openTokenCacheKey(code) + now := time.Now().Unix() + if err := putOpenTokenCacheEntry(tokenCacheKey, openTokenCacheEntry{ + UserID: opts.UserID, + CodespaceUUID: opts.CodespaceUUID, + EndpointID: opts.EndpointID, + ManagerID: codespace.ManagerID, + IssuedUnix: now, + ExpiresUnix: now + int64(setting.Codespace.OpenTokenExpire/time.Second), + }); err != nil { + return err + } + redirectURL, err := gatewayOpenURL(gatewayURL, opts.CodespaceUUID, opts.EndpointID, code) + if err != nil { + return err + } + nextGeneration, err := advanceCodespaceInteraction(ctx, codespace, now) + if err != nil { + return err + } + result = &openEndpointResult{ + redirectURL: redirectURL, + interactionGeneration: nextGeneration, + code: code, + managerID: codespace.ManagerID, + } + return nil + }) + }) + if err != nil { + if tokenCacheKey != "" { + _ = deleteOpenTokenCacheEntry(tokenCacheKey) + } + return nil, err + } + if unavailableCategory != "" { + return nil, fmt.Errorf("%w: %s", ErrOpenEndpointUnavailable, unavailableCategory) + } + return result, nil +} + +// ValidateOpenTokenOptions contains one Gateway authorization-code exchange. +type ValidateOpenTokenOptions struct { + Code string +} + +type openTokenCacheEntry struct { + UserID int64 `json:"user_id"` + CodespaceUUID string `json:"codespace_uuid"` + EndpointID string `json:"endpoint_id"` + ManagerID int64 `json:"manager_id"` + IssuedUnix int64 `json:"issued_unix"` + ExpiresUnix int64 `json:"expires_unix"` +} + +// ValidateOpenToken validates and consumes one Gateway Open Token. +func ValidateOpenToken(ctx context.Context, manager *codespace_model.Manager, opts ValidateOpenTokenOptions) (*codespacev1.ValidateOpenTokenResponse, error) { + if manager == nil || manager.ID <= 0 { + return nil, errors.New("manager is required") + } + if !setting.Codespace.Enabled { + return denyOpenToken(OpenTokenDeniedStateUnavailable), nil + } + if !validOpenTokenCode(opts.Code) { + return denyOpenToken(OpenTokenDeniedInvalidCredentials), nil + } + key := openTokenCacheKey(opts.Code) + entry, hasEntry, badEntry, err := getOpenTokenCacheEntry(key) + if err != nil { + return nil, err + } + if badEntry { + _ = deleteOpenTokenCacheEntry(key) + return denyOpenToken(OpenTokenDeniedInvalidCredentials), nil + } + if !hasEntry { + return denyOpenToken(OpenTokenDeniedInvalidCredentials), nil + } + + var result *codespacev1.ValidateOpenTokenResponse + err = globallock.LockAndDo(ctx, codespaceStateLockKey(entry.CodespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + currentEntry, hasEntry, badEntry, err := getOpenTokenCacheEntry(key) + if err != nil { + return err + } + if badEntry { + _ = deleteOpenTokenCacheEntry(key) + result = denyOpenToken(OpenTokenDeniedInvalidCredentials) + return nil + } + if !hasEntry || currentEntry != entry { + result = denyOpenToken(OpenTokenDeniedInvalidCredentials) + return nil + } + now := time.Now().Unix() + if now >= currentEntry.ExpiresUnix { + _ = deleteOpenTokenCacheEntry(key) + result = denyOpenToken(OpenTokenDeniedInvalidCredentials) + return nil + } + if currentEntry.ManagerID != manager.ID { + result = denyOpenToken(OpenTokenDeniedManagerMismatch) + return nil + } + currentManager, err := loadCodespaceManager(ctx, manager.ID) + if err != nil { + return err + } + if currentManager.RuntimeState != codespace_model.ManagerRuntimeStateOnline || isManagerOffline(currentManager) { + result = denyOpenToken(OpenTokenDeniedStateUnavailable) + return nil + } + + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(currentEntry.CodespaceUUID).Get(codespace) + if err != nil { + return err + } + if !has { + result = denyOpenToken(OpenTokenDeniedCodespaceNotFound) + return nil + } + if codespace.ManagerID != manager.ID { + result = denyOpenToken(OpenTokenDeniedManagerMismatch) + return nil + } + if codespace.UserID != currentEntry.UserID { + result = denyOpenToken(OpenTokenDeniedPermissionDenied) + return nil + } + if codespace.Status != codespace_model.StatusRunning { + result = denyOpenToken(OpenTokenDeniedCodespaceNotRunning) + return nil + } + if hasActiveOperation(codespace) && !isQueuedIdleStop(codespace) { + result = denyOpenToken(OpenTokenDeniedStateUnavailable) + return nil + } + if err := checkCodespaceCreatorForOpen(ctx, codespace, currentEntry.UserID); err != nil { + if user_model.IsErrUserNotExist(err) || errors.Is(err, errOpenTokenLoginRestricted) { + result = denyOpenToken(OpenTokenDeniedLoginRestricted) + return nil + } + return err + } + entry, hasEntry, err := getRuntimeMetadataEntry(currentEntry.CodespaceUUID) + if err != nil { + return err + } + if !hasEntry || !runtimeMetadataReadyForRunning(codespace, entry.Metadata) { + result = denyOpenToken(OpenTokenDeniedMetadataRebuilding) + return nil + } + endpoint, found := entry.Metadata.endpointByID(currentEntry.EndpointID) + if !found || endpoint.Public { + result = denyOpenToken(OpenTokenDeniedEndpointNotFound) + return nil + } + if err := deleteOpenTokenCacheEntry(key); err != nil { + return err + } + nextGeneration, err := advanceCodespaceInteraction(ctx, codespace, now) + if err != nil { + if err == errInteractionVersionExhausted { + result = denyOpenToken(OpenTokenDeniedVersionExhausted) + return nil + } + return err + } + result = &codespacev1.ValidateOpenTokenResponse{ + Outcome: &codespacev1.ValidateOpenTokenResponse_Allowed{ + Allowed: &codespacev1.OpenTokenBinding{ + UserId: currentEntry.UserID, + CodespaceUuid: currentEntry.CodespaceUUID, + EndpointId: currentEntry.EndpointID, + InteractionGeneration: nextGeneration, + }, + }, + } + return nil + }) + }) + if err != nil { + return nil, err + } + return result, nil +} + +func checkCodespaceCreatorForOpen(ctx context.Context, codespace *codespace_model.Codespace, userID int64) error { + if userID != codespace.UserID { + return errors.New("codespace user mismatch") + } + user, err := user_model.GetUserByID(ctx, codespace.UserID) + if err != nil { + return err + } + canUseGateway, err := codespaceUserCanLogIn(ctx, user) + if err != nil { + return err + } + if !canUseGateway { + return errOpenTokenLoginRestricted + } + return nil +} + +var errInteractionVersionExhausted = errors.New("interaction generation exhausted") + +func advanceCodespaceInteraction(ctx context.Context, codespace *codespace_model.Codespace, now int64) (int64, error) { + nextGeneration, err := codespace_model.NextVersion(codespace.InteractionGeneration) + if err != nil { + return 0, errInteractionVersionExhausted + } + codespace.InteractionGeneration = nextGeneration + codespace.LastActiveUnix = now + cols := []string{"interaction_generation", "last_active_unix"} + if isQueuedIdleStop(codespace) { + codespace.UpdatedUnix = now + clearActiveOperation(codespace) + cols = append(cols, + "operation_type", + "operation_status", + "operation_trigger", + "operation_created_unix", + "operation_started_unix", + "operation_deadline_unix", + "updated_unix", + ) + } + if _, err := db.GetEngine(ctx).ID(codespace.UUID).Cols(cols...).Update(codespace); err != nil { + return 0, err + } + return nextGeneration, nil +} + +func openEndpointInfo(codespace *codespace_model.Codespace, metadata runtimeMetadata, gatewayURL string, opts OpenEndpointOptions) (*openEndpointTarget, error) { + endpoint, found := metadata.endpointByID(opts.EndpointID) + if !found { + return unavailableOpenEndpoint(OpenTokenDeniedEndpointNotFound), nil + } + targetURL, err := gatewayEndpointURL(gatewayURL, opts.CodespaceUUID, opts.EndpointID) + if err != nil { + return nil, err + } + target := &openEndpointTarget{ + redirectURL: targetURL, + public: endpoint.Public, + available: true, + } + if endpoint.Public { + if hasActiveOperation(codespace) { + target.available = false + target.unavailableCategory = OpenTokenDeniedStateUnavailable + } + return target, nil + } + if hasActiveOperation(codespace) && !isQueuedIdleStop(codespace) { + target.available = false + target.unavailableCategory = OpenTokenDeniedStateUnavailable + } + return target, nil +} + +func unavailableOpenEndpoint(category string) *openEndpointTarget { + return &openEndpointTarget{ + unavailableCategory: category, + } +} + +func validateOpenEndpointID(endpointID string) error { + if endpointID == workspaceEndpointID || endpointIDPattern.MatchString(endpointID) { + return nil + } + return errors.New("invalid endpoint_id") +} + +func loadManagerGatewayURL(ctx context.Context, managerID int64) (string, error) { + address := new(codespace_model.ManagerAddress) + has, err := db.GetEngine(ctx). + Where("manager_id = ? AND kind = ?", managerID, codespace_model.ManagerAddressGateway). + Get(address) + if err != nil { + return "", err + } + if !has { + return "", errors.New("manager gateway address not found") + } + return address.Address, nil +} + +func gatewayOpenURL(rawGatewayURL, codespaceUUID, endpointID, code string) (string, error) { + target, err := gatewayEndpointURL(rawGatewayURL, codespaceUUID, endpointID) + if err != nil { + return "", err + } + parsed, err := url.Parse(target) + if err != nil { + return "", err + } + parsed.Path = "/.gitea-codespace/open" + values := parsed.Query() + values.Set("code", code) + parsed.RawQuery = values.Encode() + return parsed.String(), nil +} + +func gatewayEndpointURL(rawGatewayURL, codespaceUUID, endpointID string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(rawGatewayURL)) + if err != nil { + return "", fmt.Errorf("parse gateway url: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", errors.New("gateway url must use http or https") + } + if parsed.Host == "" { + return "", errors.New("gateway url host is required") + } + uuid32, err := codespace_model.UUID32(codespaceUUID) + if err != nil { + return "", err + } + host := uuid32 + "." + parsed.Host + if endpointID != workspaceEndpointID { + if !endpointIDPattern.MatchString(endpointID) { + return "", errors.New("invalid endpoint_id") + } + host = endpointID + "-" + host + } + target := &url.URL{ + Scheme: parsed.Scheme, + Host: host, + Path: "/", + } + return target.String(), nil +} + +func generateOpenTokenCode() string { + return hex.EncodeToString(util.CryptoRandomBytes(32)) +} + +func validOpenTokenCode(code string) bool { + if len(code) != 64 { + return false + } + _, err := hex.DecodeString(code) + return err == nil +} + +func openTokenCacheKey(code string) string { + sum := sha256.Sum256([]byte(code)) + return "codespace:open-code:" + hex.EncodeToString(sum[:]) +} + +func putOpenTokenCacheEntry(key string, entry openTokenCacheEntry) error { + if cache.GetCache() == nil { + return errors.New("cache is not initialized") + } + return cache.GetCache().PutJSON(key, entry, int64(setting.Codespace.OpenTokenExpire/time.Second)) +} + +func getOpenTokenCacheEntry(key string) (openTokenCacheEntry, bool, bool, error) { + if cache.GetCache() == nil { + return openTokenCacheEntry{}, false, false, errors.New("cache is not initialized") + } + entry := openTokenCacheEntry{} + exists, getErr := cache.GetCache().GetJSON(key, &entry) + if getErr != nil { + return openTokenCacheEntry{}, false, true, nil + } + return entry, exists, false, nil +} + +func deleteOpenTokenCacheEntry(key string) error { + if cache.GetCache() == nil { + return errors.New("cache is not initialized") + } + return cache.GetCache().Delete(key) +} + +func denyOpenToken(category string) *codespacev1.ValidateOpenTokenResponse { + return &codespacev1.ValidateOpenTokenResponse{ + Outcome: &codespacev1.ValidateOpenTokenResponse_Denied{ + Denied: &codespacev1.FailureDetail{Category: category}, + }, + } +} diff --git a/services/codespace/open_token_test.go b/services/codespace/open_token_test.go new file mode 100644 index 0000000000000..5276e02f0bab1 --- /dev/null +++ b/services/codespace/open_token_test.go @@ -0,0 +1,262 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "math" + "testing" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + "gitea.dev/modules/cache" + "gitea.dev/modules/setting" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenEndpointTokenAllowsAndConsumes(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + insertServiceManagerGatewayAddress(t, manager, "https://gateway.example.com") + codespaceUUID := "91919191-9191-4919-8919-919191919191" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 81, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerIdle, + OperationCreatedUnix: time.Now().Unix(), + InteractionGeneration: 5, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 81, []map[string]any{ + {"endpoint_id": "app-3000", "label": "App", "public": false}, + }))) + + issued, err := openEndpoint(t.Context(), OpenEndpointOptions{ + UserID: 1, + CodespaceUUID: codespaceUUID, + EndpointID: "app-3000", + }) + require.NoError(t, err) + require.Len(t, issued.code, 64) + assert.Equal(t, manager.ID, issued.managerID) + assert.EqualValues(t, 6, issued.interactionGeneration) + assert.Regexp(t, `^https://app-3000-91919191919149198919919191919191\.gateway\.example\.com/\.gitea-codespace/open\?code=[0-9a-f]{64}$`, issued.redirectURL) + assert.True(t, cache.GetCache().IsExist(openTokenCacheKey(issued.code))) + + row := loadServiceCodespace(t, codespaceUUID) + assert.EqualValues(t, 6, row.InteractionGeneration) + assert.Empty(t, row.OperationType) + assert.Empty(t, row.OperationStatus) + + validated, err := ValidateOpenToken(t.Context(), manager, ValidateOpenTokenOptions{Code: issued.code}) + require.NoError(t, err) + require.NotNil(t, validated.GetAllowed()) + assert.EqualValues(t, 1, validated.GetAllowed().GetUserId()) + assert.Equal(t, codespaceUUID, validated.GetAllowed().GetCodespaceUuid()) + assert.Equal(t, "app-3000", validated.GetAllowed().GetEndpointId()) + assert.EqualValues(t, 7, validated.GetAllowed().GetInteractionGeneration()) + assert.False(t, cache.GetCache().IsExist(openTokenCacheKey(issued.code))) + + again, err := ValidateOpenToken(t.Context(), manager, ValidateOpenTokenOptions{Code: issued.code}) + require.NoError(t, err) + assert.Equal(t, OpenTokenDeniedInvalidCredentials, again.GetDenied().GetCategory()) +} + +func TestOpenEndpointHidesOtherCreatorCodespace(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + insertServiceManagerGatewayAddress(t, manager, "https://gateway.example.com") + codespaceUUID := "98979797-9797-4979-8979-979797979797" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 87, + }) + + _, err := OpenEndpoint(t.Context(), OpenEndpointOptions{ + UserID: 2, + CodespaceUUID: codespaceUUID, + EndpointID: "workspace", + }) + require.ErrorIs(t, err, ErrOpenEndpointNotFound) +} + +func TestValidateOpenTokenDeniesAndPreservesTemporarilyInvalidCode(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + insertServiceManagerGatewayAddress(t, manager, "https://gateway.example.com") + codespaceUUID := "92929292-9292-4929-8929-929292929292" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 82, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 82, []map[string]any{}))) + issued, err := openEndpoint(t.Context(), OpenEndpointOptions{ + UserID: 1, + CodespaceUUID: codespaceUUID, + EndpointID: "workspace", + }) + require.NoError(t, err) + + _, err = db.GetEngine(t.Context()).ID(codespaceUUID).Cols("status").Update(&codespace_model.Codespace{ + Status: codespace_model.StatusStopped, + }) + require.NoError(t, err) + result, err := ValidateOpenToken(t.Context(), manager, ValidateOpenTokenOptions{Code: issued.code}) + require.NoError(t, err) + assert.Equal(t, OpenTokenDeniedCodespaceNotRunning, result.GetDenied().GetCategory()) + assert.True(t, cache.GetCache().IsExist(openTokenCacheKey(issued.code))) +} + +func TestValidateOpenTokenDeletesExpiredOrMalformedCache(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + insertServiceManagerGatewayAddress(t, manager, "https://gateway.example.com") + codespaceUUID := "93939393-9393-4939-8939-939393939393" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 83, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 83, []map[string]any{}))) + + expiredCode := generateOpenTokenCode() + expiredKey := openTokenCacheKey(expiredCode) + require.NoError(t, putOpenTokenCacheEntry(expiredKey, openTokenCacheEntry{ + UserID: 1, + CodespaceUUID: codespaceUUID, + EndpointID: "workspace", + ManagerID: manager.ID, + IssuedUnix: time.Now().Unix() - int64(setting.Codespace.OpenTokenExpire/time.Second) - 1, + ExpiresUnix: time.Now().Unix() - 1, + })) + expired, err := ValidateOpenToken(t.Context(), manager, ValidateOpenTokenOptions{Code: expiredCode}) + require.NoError(t, err) + assert.Equal(t, OpenTokenDeniedInvalidCredentials, expired.GetDenied().GetCategory()) + assert.False(t, cache.GetCache().IsExist(expiredKey)) + + malformedCode := generateOpenTokenCode() + malformedKey := openTokenCacheKey(malformedCode) + require.NoError(t, cache.GetCache().Put(malformedKey, "{bad", int64(setting.Codespace.OpenTokenExpire/time.Second))) + malformed, err := ValidateOpenToken(t.Context(), manager, ValidateOpenTokenOptions{Code: malformedCode}) + require.NoError(t, err) + assert.Equal(t, OpenTokenDeniedInvalidCredentials, malformed.GetDenied().GetCategory()) + assert.False(t, cache.GetCache().IsExist(malformedKey)) +} + +func TestValidateOpenTokenEndpointMustRemainPrivate(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + insertServiceManagerGatewayAddress(t, manager, "https://gateway.example.com") + codespaceUUID := "94949494-9494-4949-8949-949494949494" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 84, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 84, []map[string]any{ + {"endpoint_id": "app-3000", "label": "App", "public": false}, + }))) + issued, err := openEndpoint(t.Context(), OpenEndpointOptions{ + UserID: 1, + CodespaceUUID: codespaceUUID, + EndpointID: "app-3000", + }) + require.NoError(t, err) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 84, []map[string]any{ + {"endpoint_id": "app-3000", "label": "App", "public": true}, + }))) + + result, err := ValidateOpenToken(t.Context(), manager, ValidateOpenTokenOptions{Code: issued.code}) + require.NoError(t, err) + assert.Equal(t, OpenTokenDeniedEndpointNotFound, result.GetDenied().GetCategory()) + assert.True(t, cache.GetCache().IsExist(openTokenCacheKey(issued.code))) +} + +func TestValidateOpenTokenVersionExhaustedConsumesCode(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + insertServiceManagerGatewayAddress(t, manager, "https://gateway.example.com") + codespaceUUID := "95959595-9595-4959-8959-959595959595" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 85, + InteractionGeneration: math.MaxInt64, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 85, []map[string]any{}))) + + code := generateOpenTokenCode() + key := openTokenCacheKey(code) + now := time.Now().Unix() + require.NoError(t, putOpenTokenCacheEntry(key, openTokenCacheEntry{ + UserID: 1, + CodespaceUUID: codespaceUUID, + EndpointID: "workspace", + ManagerID: manager.ID, + IssuedUnix: now, + ExpiresUnix: now + int64(setting.Codespace.OpenTokenExpire/time.Second), + })) + result, err := ValidateOpenToken(t.Context(), manager, ValidateOpenTokenOptions{Code: code}) + require.NoError(t, err) + assert.Equal(t, OpenTokenDeniedVersionExhausted, result.GetDenied().GetCategory()) + assert.False(t, cache.GetCache().IsExist(key)) +} + +func TestOpenEndpointPublicRedirectDoesNotIssueCodeOrAdvance(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + insertServiceManagerGatewayAddress(t, manager, "https://gateway.example.com") + codespaceUUID := "97979797-9797-4979-8979-979797979797" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 86, + InteractionGeneration: 11, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 86, []map[string]any{ + {"endpoint_id": "app-3000", "label": "App", "public": true}, + }))) + + result, err := OpenEndpoint(t.Context(), OpenEndpointOptions{ + UserID: 1, + CodespaceUUID: codespaceUUID, + EndpointID: "app-3000", + }) + require.NoError(t, err) + require.True(t, result.Public) + assert.Equal(t, "https://app-3000-97979797979749798979979797979797.gateway.example.com/", result.RedirectURL) + row := loadServiceCodespace(t, codespaceUUID) + assert.EqualValues(t, 11, row.InteractionGeneration) + assert.Zero(t, row.LastActiveUnix) +} + +func insertServiceManagerGatewayAddress(t *testing.T, manager *codespace_model.Manager, gatewayURL string) { + t.Helper() + require.NoError(t, db.Insert(t.Context(), &codespace_model.ManagerAddress{ + ManagerID: manager.ID, + Kind: codespace_model.ManagerAddressGateway, + Address: gatewayURL, + })) +} diff --git a/services/codespace/permission_settings.go b/services/codespace/permission_settings.go new file mode 100644 index 0000000000000..c1d37b9ffe516 --- /dev/null +++ b/services/codespace/permission_settings.go @@ -0,0 +1,180 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/perm" + repo_model "gitea.dev/models/repo" +) + +var ( + // ErrPermissionAuthorizationNotFound is returned when an authorization does not belong to the user. + ErrPermissionAuthorizationNotFound = errors.New("codespace permission authorization not found") + // ErrPermissionReductionInvalid is returned when a change would increase an approved permission. + ErrPermissionReductionInvalid = errors.New("codespace permission can only be reduced") +) + +// PermissionAuthorizationView contains one remembered authorization and its repository rules. +type PermissionAuthorizationView struct { + ID int64 + SourceRepository string + RequestHash string + Revoked bool + CreatedUnix int64 + UpdatedUnix int64 + CodespaceCount int64 + Repositories []PermissionRepositoryView +} + +// PermissionRepositoryView contains one rule shown in user settings. +type PermissionRepositoryView struct { + ID int64 + Repository string + UnitName string + RequestedMode string + GrantedMode string + CanReduceToRead bool + CanRevoke bool +} + +// ListPermissionAuthorizations returns the current user's remembered Codespace grants. +func ListPermissionAuthorizations(ctx context.Context, userID int64) ([]*PermissionAuthorizationView, error) { + var authorizations []*codespace_model.PermissionAuthorization + if err := db.GetEngine(ctx).Where("user_id = ?", userID).Desc("updated_unix", "id").Find(&authorizations); err != nil { + return nil, err + } + views := make([]*PermissionAuthorizationView, 0, len(authorizations)) + for _, authorization := range authorizations { + sourceRepo, err := repo_model.GetRepositoryByID(ctx, authorization.SourceRepoID) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + continue + } + return nil, err + } + if err := sourceRepo.LoadOwner(ctx); err != nil { + return nil, err + } + var rules []*codespace_model.PermissionRepository + if err := db.GetEngine(ctx).Where("authorization_id = ?", authorization.ID).Asc("target_repo_id", "unit_type").Find(&rules); err != nil { + return nil, err + } + view := &PermissionAuthorizationView{ + ID: authorization.ID, SourceRepository: sourceRepo.FullName(), RequestHash: authorization.RequestHash, + Revoked: authorization.RevokedUnix != 0, CreatedUnix: authorization.CreatedUnix, UpdatedUnix: authorization.UpdatedUnix, + } + if !view.Revoked { + count, err := db.GetEngine(ctx).Where("permission_authorization_id = ?", authorization.ID).Count(new(codespace_model.Codespace)) + if err != nil { + return nil, err + } + view.CodespaceCount = count + } + for _, rule := range rules { + targetRepo, err := repo_model.GetRepositoryByID(ctx, rule.TargetRepoID) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + continue + } + return nil, err + } + if err := targetRepo.LoadOwner(ctx); err != nil { + return nil, err + } + unitName := fmt.Sprintf("unit-%d", rule.UnitType) + for name, unitType := range codespacePermissionUnits { + if unitType == rule.UnitType { + unitName = name + break + } + } + view.Repositories = append(view.Repositories, PermissionRepositoryView{ + ID: rule.ID, Repository: targetRepo.FullName(), UnitName: unitName, + RequestedMode: rule.RequestedMode.ToString(), GrantedMode: rule.GrantedMode.ToString(), + CanReduceToRead: !view.Revoked && rule.GrantedMode == perm.AccessModeWrite, + CanRevoke: !view.Revoked && rule.GrantedMode != perm.AccessModeNone, + }) + } + views = append(views, view) + } + return views, nil +} + +// RevokePermissionAuthorization revokes all grants in one remembered authorization. +func RevokePermissionAuthorization(ctx context.Context, userID, authorizationID int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + authorization, err := getUserPermissionAuthorization(ctx, userID, authorizationID) + if err != nil { + return err + } + if authorization.RevokedUnix != 0 { + return nil + } + now := time.Now().Unix() + updated, err := db.GetEngine(ctx).ID(authorization.ID).Where("revoked_unix = 0").Cols("revoked_unix", "updated_unix").Update(&codespace_model.PermissionAuthorization{RevokedUnix: now, UpdatedUnix: now}) + if err != nil { + return err + } + if updated != 1 { + return ErrPermissionAuthorizationNotFound + } + return nil + }) +} + +// ReducePermissionRepository lowers one rule to read or none. +func ReducePermissionRepository(ctx context.Context, userID, authorizationID, ruleID int64, mode perm.AccessMode) error { + if mode != perm.AccessModeNone && mode != perm.AccessModeRead { + return ErrPermissionReductionInvalid + } + return db.WithTx(ctx, func(ctx context.Context) error { + authorization, err := getUserPermissionAuthorization(ctx, userID, authorizationID) + if err != nil || authorization.RevokedUnix != 0 { + return ErrPermissionAuthorizationNotFound + } + rule := new(codespace_model.PermissionRepository) + has, err := db.GetEngine(ctx).ID(ruleID).Where("authorization_id = ?", authorization.ID).Get(rule) + if err != nil { + return err + } + if !has { + return ErrPermissionAuthorizationNotFound + } + if mode > rule.GrantedMode || mode > rule.RequestedMode { + return ErrPermissionReductionInvalid + } + if mode == rule.GrantedMode { + return nil + } + updated, err := db.GetEngine(ctx).ID(rule.ID).Where("granted_mode = ?", rule.GrantedMode).Cols("granted_mode").Update(&codespace_model.PermissionRepository{GrantedMode: mode}) + if err != nil { + return err + } + if updated != 1 { + return ErrPermissionReductionInvalid + } + now := time.Now().Unix() + _, err = db.GetEngine(ctx).ID(authorization.ID).Cols("updated_unix").Update(&codespace_model.PermissionAuthorization{UpdatedUnix: now}) + return err + }) +} + +func getUserPermissionAuthorization(ctx context.Context, userID, authorizationID int64) (*codespace_model.PermissionAuthorization, error) { + authorization := new(codespace_model.PermissionAuthorization) + has, err := db.GetEngine(ctx).ID(authorizationID).Where("user_id = ?", userID).Get(authorization) + if err != nil { + return nil, err + } + if !has { + return nil, ErrPermissionAuthorizationNotFound + } + return authorization, nil +} diff --git a/services/codespace/permission_settings_test.go b/services/codespace/permission_settings_test.go new file mode 100644 index 0000000000000..5beb5d6dbc15f --- /dev/null +++ b/services/codespace/permission_settings_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/perm" + "gitea.dev/models/unit" + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReduceAndRevokePermissionAuthorization(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + now := time.Now().Unix() + authorization := &codespace_model.PermissionAuthorization{ + UserID: 2, SourceRepoID: 1, RequestHash: "request", + CreatedUnix: now, UpdatedUnix: now, + } + require.NoError(t, db.Insert(t.Context(), authorization)) + rule := &codespace_model.PermissionRepository{ + AuthorizationID: authorization.ID, + TargetRepoID: 2, + UnitType: unit.TypeCode, + RequestedMode: perm.AccessModeWrite, + GrantedMode: perm.AccessModeWrite, + } + require.NoError(t, db.Insert(t.Context(), rule)) + otherAuthorization := &codespace_model.PermissionAuthorization{ + UserID: 2, SourceRepoID: 3, RequestHash: "other-request", + CreatedUnix: now, UpdatedUnix: now, + } + require.NoError(t, db.Insert(t.Context(), otherAuthorization)) + otherRule := &codespace_model.PermissionRepository{ + AuthorizationID: otherAuthorization.ID, + TargetRepoID: 1, + UnitType: unit.TypeCode, + RequestedMode: perm.AccessModeWrite, + GrantedMode: perm.AccessModeWrite, + } + require.NoError(t, db.Insert(t.Context(), otherRule)) + + require.ErrorIs(t, ReducePermissionRepository(t.Context(), 4, authorization.ID, rule.ID, perm.AccessModeRead), ErrPermissionAuthorizationNotFound) + require.ErrorIs(t, RevokePermissionAuthorization(t.Context(), 4, authorization.ID), ErrPermissionAuthorizationNotFound) + require.ErrorIs(t, ReducePermissionRepository(t.Context(), 2, authorization.ID, otherRule.ID, perm.AccessModeRead), ErrPermissionAuthorizationNotFound) + + require.NoError(t, ReducePermissionRepository(t.Context(), 2, authorization.ID, rule.ID, perm.AccessModeRead)) + rule = unittest.AssertExistsAndLoadBean(t, &codespace_model.PermissionRepository{ID: rule.ID}) + assert.Equal(t, perm.AccessModeRead, rule.GrantedMode) + require.NoError(t, ReducePermissionRepository(t.Context(), 2, authorization.ID, rule.ID, perm.AccessModeNone)) + require.ErrorIs(t, ReducePermissionRepository(t.Context(), 2, authorization.ID, rule.ID, perm.AccessModeRead), ErrPermissionReductionInvalid) + rule = unittest.AssertExistsAndLoadBean(t, &codespace_model.PermissionRepository{ID: rule.ID}) + assert.Equal(t, perm.AccessModeNone, rule.GrantedMode) + + require.NoError(t, RevokePermissionAuthorization(t.Context(), 2, authorization.ID)) + authorization = unittest.AssertExistsAndLoadBean(t, &codespace_model.PermissionAuthorization{ID: authorization.ID}) + assert.Positive(t, authorization.RevokedUnix) + require.ErrorIs(t, ReducePermissionRepository(t.Context(), 2, authorization.ID, rule.ID, perm.AccessModeNone), ErrPermissionAuthorizationNotFound) +} diff --git a/services/codespace/public_endpoint.go b/services/codespace/public_endpoint.go new file mode 100644 index 0000000000000..72d3e4900b3b3 --- /dev/null +++ b/services/codespace/public_endpoint.go @@ -0,0 +1,107 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "fmt" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/modules/setting" +) + +const ( + // PublicEndpointDeniedInvalidEndpoint means the endpoint id is not a public endpoint id. + PublicEndpointDeniedInvalidEndpoint = "invalid_endpoint" + // PublicEndpointDeniedCodespaceNotFound means the Codespace no longer exists. + PublicEndpointDeniedCodespaceNotFound = "codespace_not_found" + // PublicEndpointDeniedManagerMismatch means the Codespace is bound to another Manager. + PublicEndpointDeniedManagerMismatch = "manager_mismatch" + // PublicEndpointDeniedManagerOffline means the Manager is not online. + PublicEndpointDeniedManagerOffline = "manager_offline" + // PublicEndpointDeniedStateUnavailable means the lifecycle state cannot serve public traffic. + PublicEndpointDeniedStateUnavailable = "state_unavailable" + // PublicEndpointDeniedActiveOperation means a lifecycle operation is active. + PublicEndpointDeniedActiveOperation = "active_operation" + // PublicEndpointDeniedMetadataRebuilding means Runtime Metadata is absent or not ready. + PublicEndpointDeniedMetadataRebuilding = "metadata_rebuilding" + // PublicEndpointDeniedEndpointNotPublic means the endpoint is absent or private. + PublicEndpointDeniedEndpointNotPublic = "endpoint_not_public" +) + +// ValidatePublicEndpointOptions identifies one public Endpoint authorization request. +type ValidatePublicEndpointOptions struct { + CodespaceUUID string + EndpointID string +} + +// ValidatePublicEndpoint authorizes unauthenticated traffic to a public Runtime Endpoint. +func ValidatePublicEndpoint(ctx context.Context, manager *codespace_model.Manager, opts ValidatePublicEndpointOptions) (*codespacev1.ValidatePublicEndpointResponse, error) { + if manager == nil || manager.ID <= 0 { + return nil, fmt.Errorf("manager is required") + } + if !setting.Codespace.Enabled { + return denyPublicEndpoint(PublicEndpointDeniedStateUnavailable), nil + } + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return nil, err + } + if !validPublicEndpointID(opts.EndpointID) { + return denyPublicEndpoint(PublicEndpointDeniedInvalidEndpoint), nil + } + currentManager, err := loadCodespaceManager(ctx, manager.ID) + if err != nil { + return nil, err + } + if currentManager.RuntimeState != codespace_model.ManagerRuntimeStateOnline || isManagerOffline(currentManager) { + return denyPublicEndpoint(PublicEndpointDeniedManagerOffline), nil + } + + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceUUID).Get(codespace) + if err != nil { + return nil, err + } + if !has { + return denyPublicEndpoint(PublicEndpointDeniedCodespaceNotFound), nil + } + if codespace.ManagerID != manager.ID { + return denyPublicEndpoint(PublicEndpointDeniedManagerMismatch), nil + } + if codespace.Status != codespace_model.StatusRunning { + return denyPublicEndpoint(PublicEndpointDeniedStateUnavailable), nil + } + if hasActiveOperation(codespace) { + return denyPublicEndpoint(PublicEndpointDeniedActiveOperation), nil + } + + entry, hasEntry, err := getRuntimeMetadataEntry(opts.CodespaceUUID) + if err != nil { + return nil, err + } + if !hasEntry || !runtimeMetadataReadyForRunning(codespace, entry.Metadata) { + return denyPublicEndpoint(PublicEndpointDeniedMetadataRebuilding), nil + } + endpoint, found := entry.Metadata.endpointByID(opts.EndpointID) + if !found || !endpoint.Public { + return denyPublicEndpoint(PublicEndpointDeniedEndpointNotPublic), nil + } + return &codespacev1.ValidatePublicEndpointResponse{ + Outcome: &codespacev1.ValidatePublicEndpointResponse_Allowed{Allowed: &codespacev1.PublicEndpointAllowed{}}, + }, nil +} + +func validPublicEndpointID(endpointID string) bool { + return endpointID != workspaceEndpointID && endpointIDPattern.MatchString(endpointID) +} + +func denyPublicEndpoint(category string) *codespacev1.ValidatePublicEndpointResponse { + return &codespacev1.ValidatePublicEndpointResponse{ + Outcome: &codespacev1.ValidatePublicEndpointResponse_Denied{ + Denied: &codespacev1.FailureDetail{Category: category}, + }, + } +} diff --git a/services/codespace/public_endpoint_test.go b/services/codespace/public_endpoint_test.go new file mode 100644 index 0000000000000..61460a6e74aca --- /dev/null +++ b/services/codespace/public_endpoint_test.go @@ -0,0 +1,202 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidatePublicEndpointAllowsPublicReadyEndpoint(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "61616161-6161-4616-8616-616161616161" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 12, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 12, []map[string]any{ + {"endpoint_id": "app-3000", "label": "App", "public": true}, + {"endpoint_id": "private-api", "label": "API", "public": false}, + }))) + + result, err := ValidatePublicEndpoint(t.Context(), manager, ValidatePublicEndpointOptions{ + CodespaceUUID: codespaceUUID, + EndpointID: "app-3000", + }) + require.NoError(t, err) + assert.NotNil(t, result.GetAllowed()) +} + +func TestValidatePublicEndpointDeniesPrivateMissingAndWorkspace(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "62626262-6262-4626-8626-626262626262" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 13, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 13, []map[string]any{ + {"endpoint_id": "private-api", "label": "API", "public": false}, + }))) + + for _, tc := range []struct { + endpointID string + category string + }{ + {"private-api", PublicEndpointDeniedEndpointNotPublic}, + {"missing", PublicEndpointDeniedEndpointNotPublic}, + {"workspace", PublicEndpointDeniedInvalidEndpoint}, + } { + result, err := ValidatePublicEndpoint(t.Context(), manager, ValidatePublicEndpointOptions{ + CodespaceUUID: codespaceUUID, + EndpointID: tc.endpointID, + }) + require.NoError(t, err) + assert.Equal(t, tc.category, result.GetDenied().GetCategory()) + } +} + +func TestValidatePublicEndpointDeniesStateAndMetadata(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + otherManager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + markServiceManagerOnline(t, otherManager, `[{"tag":"default"}]`) + activeUUID := "63636363-6363-4636-8636-636363636363" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: activeUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 14, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerIdle, + OperationCreatedUnix: time.Now().Unix(), + }) + require.NoError(t, putRuntimeMetadataEntry(activeUUID, serviceRuntimeMetadataEntry(t, 14, []map[string]any{ + {"endpoint_id": "app-3000", "label": "App", "public": true}, + }))) + + result, err := ValidatePublicEndpoint(t.Context(), manager, ValidatePublicEndpointOptions{ + CodespaceUUID: activeUUID, + EndpointID: "app-3000", + }) + require.NoError(t, err) + assert.Equal(t, PublicEndpointDeniedActiveOperation, result.GetDenied().GetCategory()) + + missingMetadataUUID := "64646464-6464-4646-8646-646464646464" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: missingMetadataUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 15, + }) + result, err = ValidatePublicEndpoint(t.Context(), manager, ValidatePublicEndpointOptions{ + CodespaceUUID: missingMetadataUUID, + EndpointID: "app-3000", + }) + require.NoError(t, err) + assert.Equal(t, PublicEndpointDeniedMetadataRebuilding, result.GetDenied().GetCategory()) + + result, err = ValidatePublicEndpoint(t.Context(), otherManager, ValidatePublicEndpointOptions{ + CodespaceUUID: missingMetadataUUID, + EndpointID: "app-3000", + }) + require.NoError(t, err) + assert.Equal(t, PublicEndpointDeniedManagerMismatch, result.GetDenied().GetCategory()) + + manager.RuntimeState = codespace_model.ManagerRuntimeStateRecovering + _, err = db.GetEngine(t.Context()).ID(manager.ID).Cols("runtime_state").Update(manager) + require.NoError(t, err) + result, err = ValidatePublicEndpoint(t.Context(), manager, ValidatePublicEndpointOptions{ + CodespaceUUID: missingMetadataUUID, + EndpointID: "app-3000", + }) + require.NoError(t, err) + assert.Equal(t, PublicEndpointDeniedManagerOffline, result.GetDenied().GetCategory()) +} + +func serviceRuntimeMetadataEntry(t *testing.T, operationRVersion int64, endpoints []map[string]any) runtimeMetadataCacheEntry { + t.Helper() + metadata, hash, err := normalizeRuntimeMetadata(serviceRuntimeMetadataProto(t, operationRVersion, bootStageReady, endpoints)) + require.NoError(t, err) + return runtimeMetadataCacheEntry{ + Metadata: metadata, + Generation: 1, + ContentHash: hash, + LastReportedUnix: time.Now().Unix(), + } +} + +func serviceRuntimeMetadataProto(t *testing.T, operationRVersion int64, stage string, endpoints []map[string]any) *codespacev1.RuntimeMetadata { + t.Helper() + metadataEndpoints := make([]*codespacev1.RuntimeEndpoint, 0, len(endpoints)+1) + for _, endpoint := range endpoints { + metadataEndpoints = append(metadataEndpoints, &codespacev1.RuntimeEndpoint{ + EndpointId: endpoint["endpoint_id"].(string), + Label: endpoint["label"].(string), + Public: endpoint["public"].(bool), + }) + } + if stage == bootStagePublishReady || stage == bootStageReady { + metadataEndpoints = append(metadataEndpoints, &codespacev1.RuntimeEndpoint{ + EndpointId: workspaceEndpointID, + Label: workspaceEndpointLabel, + }) + } + return &codespacev1.RuntimeMetadata{ + Endpoints: metadataEndpoints, + Boot: &codespacev1.RuntimeBoot{ + OperationRversion: operationRVersion, + Stage: serviceRuntimeMetadataBootStage(t, stage), + StartedUnix: 100, + LastUpdateUnix: 101, + }, + ResourceUsage: serviceRuntimeMetadataResourceUsage(), + } +} + +func serviceRuntimeMetadataResourceUsage() *codespacev1.RuntimeResourceUsage { + return &codespacev1.RuntimeResourceUsage{ + Cpu: &codespacev1.RuntimeCPUUsage{UsedMillicores: 125, LimitMillicores: 1000}, + Memory: &codespacev1.RuntimeMemoryUsage{UsedBytes: 256 * 1024 * 1024, LimitBytes: 1024 * 1024 * 1024}, + Disk: &codespacev1.RuntimeDiskUsage{UsedBytes: 512 * 1024 * 1024, LimitBytes: 10 * 1024 * 1024 * 1024}, + ObservedUnix: 101, + } +} + +func serviceRuntimeMetadataBootStage(t *testing.T, stage string) codespacev1.RuntimeBootStage { + t.Helper() + switch stage { + case bootStagePrepareRuntime: + return codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_PREPARE_RUNTIME + case bootStageInitializeSystem: + return codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_INITIALIZE_SYSTEM + case bootStagePrepareWorkspace: + return codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_PREPARE_WORKSPACE + case bootStageStartEnvironment: + return codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_START_ENVIRONMENT + case bootStagePublishReady: + return codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_PUBLISH_READY + case bootStageReady: + return codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_READY + default: + t.Fatalf("unknown runtime metadata stage %q", stage) + return codespacev1.RuntimeBootStage_RUNTIME_BOOT_STAGE_UNSPECIFIED + } +} diff --git a/services/codespace/reconcile.go b/services/codespace/reconcile.go new file mode 100644 index 0000000000000..ae2a18b892bd1 --- /dev/null +++ b/services/codespace/reconcile.go @@ -0,0 +1,186 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/modules/globallock" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" +) + +const reconcileCodespacesBatchSize = 100 + +// ReconcileCodespacesOptions contains the retention policy for the Codespace maintenance task. +type ReconcileCodespacesOptions struct { + FailedOlderThan time.Duration +} + +// ReconcileCodespacesResult reports how many records were changed by the maintenance task. +type ReconcileCodespacesResult struct { + QueuedTimedOut int + RunningTimedOut int + FailedDeleted int +} + +// ReconcileCodespaces applies database-only Codespace time boundaries. +func ReconcileCodespaces(ctx context.Context, opts ReconcileCodespacesOptions) (*ReconcileCodespacesResult, error) { + if opts.FailedOlderThan <= 0 { + return nil, errors.New("failed retention duration must be positive") + } + + now := time.Now().Unix() + result := &ReconcileCodespacesResult{} + err := errors.Join( + reconcileQueuedOperationTimeouts(ctx, now, result), + reconcileRunningOperationTimeouts(ctx, now, result), + reconcileFailedCodespaces(ctx, now, opts.FailedOlderThan, result), + ) + return result, err +} + +func reconcileQueuedOperationTimeouts(ctx context.Context, now int64, result *ReconcileCodespacesResult) error { + var rows []*codespace_model.Codespace + if err := db.GetEngine(ctx). + Where("operation_status = ? AND operation_created_unix > 0 AND operation_created_unix <= ?", + codespace_model.OperationStatusQueued, now-int64(setting.Codespace.QueueTimeout/time.Second)). + Asc("operation_created_unix", "uuid"). + Limit(reconcileCodespacesBatchSize). + Find(&rows); err != nil { + return err + } + + var errs []error + for _, row := range rows { + if err := reconcileQueuedOperationTimeout(ctx, row.UUID, now, result); err != nil { + log.Error("Failed to reconcile queued Codespace operation %s: %v", row.UUID, err) + errs = append(errs, fmt.Errorf("queued operation %s: %w", row.UUID, err)) + } + } + return errors.Join(errs...) +} + +func reconcileQueuedOperationTimeout(ctx context.Context, codespaceUUID string, now int64, result *ReconcileCodespacesResult) error { + var summary *internalStateSummary + err := globallock.LockAndDo(ctx, codespaceStateLockKey(codespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(codespaceUUID).Get(codespace) + if err != nil || !has { + return err + } + if codespace.OperationStatus != codespace_model.OperationStatusQueued || !isQueuedExpired(codespace, time.Unix(now, 0)) { + return nil + } + summary = operationTimeoutSummary(codespace, queuedTimeoutStatus(codespace.OperationType)) + if err := applyQueuedTimeout(ctx, codespace, now); err != nil { + return err + } + result.QueuedTimedOut++ + return nil + }) + }) + if err != nil { + return err + } + appendInternalStateSummary(ctx, summary) + return nil +} + +func reconcileRunningOperationTimeouts(ctx context.Context, now int64, result *ReconcileCodespacesResult) error { + var rows []*codespace_model.Codespace + if err := db.GetEngine(ctx). + Where("operation_status = ? AND operation_deadline_unix > 0 AND operation_deadline_unix <= ?", + codespace_model.OperationStatusRunning, now). + Asc("operation_deadline_unix", "uuid"). + Limit(reconcileCodespacesBatchSize). + Find(&rows); err != nil { + return err + } + + var errs []error + for _, row := range rows { + if err := reconcileRunningOperationTimeout(ctx, row.UUID, now, result); err != nil { + log.Error("Failed to reconcile running Codespace operation %s: %v", row.UUID, err) + errs = append(errs, fmt.Errorf("running operation %s: %w", row.UUID, err)) + } + } + return errors.Join(errs...) +} + +func reconcileRunningOperationTimeout(ctx context.Context, codespaceUUID string, now int64, result *ReconcileCodespacesResult) error { + var summary *internalStateSummary + err := globallock.LockAndDo(ctx, codespaceStateLockKey(codespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(codespaceUUID).Get(codespace) + if err != nil || !has { + return err + } + if codespace.OperationStatus != codespace_model.OperationStatusRunning || + codespace.OperationDeadlineUnix <= 0 || + codespace.OperationDeadlineUnix > now { + return nil + } + summary = operationTimeoutSummary(codespace, timeoutStatus(codespace.OperationType)) + if err := applyRunningTimeout(ctx, codespace, now); err != nil { + return err + } + result.RunningTimedOut++ + return nil + }) + }) + if err != nil { + return err + } + appendInternalStateSummary(ctx, summary) + return nil +} + +func reconcileFailedCodespaces(ctx context.Context, now int64, olderThan time.Duration, result *ReconcileCodespacesResult) error { + cutoff := now - int64(olderThan/time.Second) + var rows []*codespace_model.Codespace + if err := db.GetEngine(ctx). + Where("status = ? AND updated_unix > 0 AND updated_unix <= ?", codespace_model.StatusFailed, cutoff). + Asc("updated_unix", "uuid"). + Limit(reconcileCodespacesBatchSize). + Find(&rows); err != nil { + return err + } + + var errs []error + for _, row := range rows { + if err := reconcileFailedCodespace(ctx, row.UUID, cutoff, result); err != nil { + log.Error("Failed to delete expired failed Codespace %s: %v", row.UUID, err) + errs = append(errs, fmt.Errorf("failed codespace %s: %w", row.UUID, err)) + } + } + return errors.Join(errs...) +} + +func reconcileFailedCodespace(ctx context.Context, codespaceUUID string, cutoff int64, result *ReconcileCodespacesResult) error { + return globallock.LockAndDo(ctx, codespaceStateLockKey(codespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(codespaceUUID).Get(codespace) + if err != nil || !has { + return err + } + if codespace.Status != codespace_model.StatusFailed || codespace.UpdatedUnix <= 0 || codespace.UpdatedUnix > cutoff { + return nil + } + if err := deleteCodespaceForFinal(ctx, codespace.UUID); err != nil { + return err + } + result.FailedDeleted++ + return nil + }) + }) +} diff --git a/services/codespace/reconcile_test.go b/services/codespace/reconcile_test.go new file mode 100644 index 0000000000000..dc47b0de1eb54 --- /dev/null +++ b/services/codespace/reconcile_test.go @@ -0,0 +1,118 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReconcileCodespacesAppliesTimeoutsAndRetention(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + now := time.Now().Unix() + queuedUUID := "12121212-1212-4212-8212-121212121212" + runningUUID := "13131313-1313-4313-8313-131313131313" + failedUUID := "14141414-1414-4414-8414-141414141414" + freshFailedUUID := "15151515-1515-4515-8515-151515151515" + + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: queuedUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 3, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: now - int64(setting.Codespace.QueueTimeout/time.Second) - 1, + }) + insertServiceCredentials(t, queuedUUID) + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: runningUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 4, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 1, + OperationStartedUnix: now - int64(setting.Codespace.OperationMaxDuration/time.Second) - 1, + OperationDeadlineUnix: now - 1, + }) + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: failedUUID, + Status: codespace_model.StatusFailed, + UpdatedUnix: now - int64((2*time.Hour)/time.Second), + }) + _, err := db.GetEngine(t.Context()).ID(failedUUID).Cols("updated_unix").Update(&codespace_model.Codespace{ + UpdatedUnix: now - int64((2*time.Hour)/time.Second), + }) + require.NoError(t, err) + insertServiceCredentials(t, failedUUID) + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: freshFailedUUID, + Status: codespace_model.StatusFailed, + UpdatedUnix: now, + }) + _, err = db.GetEngine(t.Context()).ID(freshFailedUUID).Cols("updated_unix").Update(&codespace_model.Codespace{UpdatedUnix: now}) + require.NoError(t, err) + + result, err := ReconcileCodespaces(t.Context(), ReconcileCodespacesOptions{FailedOlderThan: time.Hour}) + require.NoError(t, err) + assert.Equal(t, 1, result.QueuedTimedOut) + assert.Equal(t, 1, result.RunningTimedOut) + assert.Equal(t, 1, result.FailedDeleted) + + queued := loadServiceCodespace(t, queuedUUID) + assert.Equal(t, codespace_model.StatusRunning, queued.Status) + assert.Empty(t, queued.OperationStatus) + assertServiceExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", queuedUUID) + assertServiceExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", queuedUUID) + + running := loadServiceCodespace(t, runningUUID) + assert.Equal(t, codespace_model.StatusFailed, running.Status) + assert.Empty(t, running.OperationStatus) + + assertServiceNotExists(t, new(codespace_model.Codespace), "uuid = ?", failedUUID) + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", failedUUID) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", failedUUID) + assertServiceExists(t, new(codespace_model.Codespace), "uuid = ?", freshFailedUUID) +} + +func TestReconcileCodespacesRequiresPositiveRetention(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + result, err := ReconcileCodespaces(t.Context(), ReconcileCodespacesOptions{}) + require.Error(t, err) + assert.Nil(t, result) +} + +func TestReconcileCodespacesSkipsChangedRows(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "16161616-1616-4616-8616-161616161616" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationStatus: codespace_model.OperationStatusQueued, + UpdatedUnix: 1, + }) + + result, err := ReconcileCodespaces(t.Context(), ReconcileCodespacesOptions{FailedOlderThan: time.Hour}) + require.NoError(t, err) + assert.Zero(t, result.QueuedTimedOut) + + var count int64 + count, err = db.GetEngine(t.Context()).Where("uuid = ?", codespaceUUID).Count(new(codespace_model.Codespace)) + require.NoError(t, err) + assert.EqualValues(t, 1, count) +} diff --git a/services/codespace/runtime_transition.go b/services/codespace/runtime_transition.go new file mode 100644 index 0000000000000..5db92adc9a2d6 --- /dev/null +++ b/services/codespace/runtime_transition.go @@ -0,0 +1,164 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/modules/globallock" +) + +var ( + // ErrRuntimeTransitionNotFound is returned when the Codespace no longer exists. + ErrRuntimeTransitionNotFound = errors.New("codespace not found") + // ErrRuntimeTransitionManagerMismatch is returned when the Codespace is bound to another Manager. + ErrRuntimeTransitionManagerMismatch = errors.New("codespace belongs to another manager") + // ErrRuntimeTransitionCurrentOperationConflict is returned when an active operation owns the current lifecycle. + ErrRuntimeTransitionCurrentOperationConflict = errors.New("current operation conflicts with runtime transition") + // ErrRuntimeTransitionManagerOffline is returned when the authenticated Manager must declare before reporting. + ErrRuntimeTransitionManagerOffline = errors.New("manager is offline") + // ErrRuntimeTransitionStaleOperation is returned when the report cannot apply to the current lifecycle state. + ErrRuntimeTransitionStaleOperation = errors.New("runtime transition is stale") + // ErrRuntimeTransitionGenerationConflict is returned when one generation maps to a different target state. + ErrRuntimeTransitionGenerationConflict = errors.New("runtime transition generation conflict") +) + +// ReportRuntimeTransitionOptions contains one Manager runtime state fact. +type ReportRuntimeTransitionOptions struct { + CodespaceUUID string + RuntimeGeneration int64 + ObservedOperationRVersion int64 + RuntimeState codespacev1.RuntimeState +} + +// ReportRuntimeTransition stores one stopped or failed Runtime state fact. +func ReportRuntimeTransition(ctx context.Context, manager *codespace_model.Manager, opts ReportRuntimeTransitionOptions) error { + if manager == nil || manager.ID <= 0 { + return errors.New("manager is required") + } + if err := validateRuntimeTransitionOptions(opts); err != nil { + return err + } + + var summary *internalStateSummary + err := globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceUUID).Get(codespace) + if err != nil { + return err + } + if !has { + return ErrRuntimeTransitionNotFound + } + if codespace.ManagerID != manager.ID { + return ErrRuntimeTransitionManagerMismatch + } + if transitionActiveOperationConflicts(codespace) { + return ErrRuntimeTransitionCurrentOperationConflict + } + + currentManager, err := loadCodespaceManager(ctx, manager.ID) + if err != nil { + return err + } + if !managerAllowsOnlineOrRecovering(currentManager) { + return ErrRuntimeTransitionManagerOffline + } + if opts.ObservedOperationRVersion != codespace.OperationRVersion { + return ErrRuntimeTransitionStaleOperation + } + if opts.RuntimeGeneration < codespace.RuntimeGeneration { + return &StaleGenerationError{CurrentGeneration: codespace.RuntimeGeneration} + } + targetStatus := runtimeTransitionTargetStatus(opts.RuntimeState) + if opts.RuntimeGeneration == codespace.RuntimeGeneration { + if codespace.Status == targetStatus { + return nil + } + return ErrRuntimeTransitionGenerationConflict + } + if !runtimeTransitionCompatible(codespace.Status, targetStatus) { + return ErrRuntimeTransitionStaleOperation + } + if err := applyRuntimeTransition(ctx, codespace, targetStatus, opts.RuntimeGeneration, time.Now().Unix()); err != nil { + return err + } + summary = &internalStateSummary{ + CodespaceUUID: codespace.UUID, + Message: fmt.Sprintf("Gitea recorded runtime generation %d as %s.", opts.RuntimeGeneration, targetStatus), + } + return nil + }) + }) + if err != nil { + return err + } + appendInternalStateSummary(ctx, summary) + return nil +} + +func validateRuntimeTransitionOptions(opts ReportRuntimeTransitionOptions) error { + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return err + } + if opts.RuntimeGeneration <= 0 { + return errors.New("runtime_generation must be positive") + } + if opts.ObservedOperationRVersion <= 0 { + return errors.New("observed_operation_rversion must be positive") + } + switch opts.RuntimeState { + case codespacev1.RuntimeState_RUNTIME_STATE_STOPPED, codespacev1.RuntimeState_RUNTIME_STATE_FAILED: + return nil + default: + return errors.New("runtime_state must be stopped or failed") + } +} + +func transitionActiveOperationConflicts(codespace *codespace_model.Codespace) bool { + return (codespace.Status == codespace_model.StatusRunning || codespace.Status == codespace_model.StatusStopped) && + hasActiveOperation(codespace) +} + +func runtimeTransitionTargetStatus(runtimeState codespacev1.RuntimeState) string { + switch runtimeState { + case codespacev1.RuntimeState_RUNTIME_STATE_STOPPED: + return codespace_model.StatusStopped + case codespacev1.RuntimeState_RUNTIME_STATE_FAILED: + return codespace_model.StatusFailed + default: + return "" + } +} + +func runtimeTransitionCompatible(currentStatus, targetStatus string) bool { + switch targetStatus { + case codespace_model.StatusStopped: + return currentStatus == codespace_model.StatusRunning + case codespace_model.StatusFailed: + return currentStatus == codespace_model.StatusRunning || currentStatus == codespace_model.StatusStopped + default: + return false + } +} + +func applyRuntimeTransition(ctx context.Context, codespace *codespace_model.Codespace, targetStatus string, runtimeGeneration, now int64) error { + codespace.Status = targetStatus + codespace.RuntimeGeneration = runtimeGeneration + codespace.UpdatedUnix = now + cols := []string{"status", "runtime_generation", "updated_unix"} + if err := cleanupCredentialsForStatus(ctx, codespace.UUID, targetStatus); err != nil { + return err + } + deleteRuntimeMetadata(codespace.UUID) + _, err := db.GetEngine(ctx).ID(codespace.UUID).Cols(cols...).Update(codespace) + return err +} diff --git a/services/codespace/runtime_transition_test.go b/services/codespace/runtime_transition_test.go new file mode 100644 index 0000000000000..82a77e3631fc6 --- /dev/null +++ b/services/codespace/runtime_transition_test.go @@ -0,0 +1,182 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReportRuntimeTransitionStoppedFromRunning(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "91919191-9191-4919-8919-919191919191" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 71, + RuntimeGeneration: 3, + }) + insertServiceCredentials(t, codespaceUUID) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 71, []map[string]any{}))) + + err := ReportRuntimeTransition(t.Context(), manager, ReportRuntimeTransitionOptions{ + CodespaceUUID: codespaceUUID, + RuntimeGeneration: 4, + ObservedOperationRVersion: 71, + RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED, + }) + require.NoError(t, err) + + row := loadServiceCodespace(t, codespaceUUID) + assert.Equal(t, codespace_model.StatusStopped, row.Status) + assert.EqualValues(t, 4, row.RuntimeGeneration) + assert.Positive(t, row.UpdatedUnix) + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", codespaceUUID) + assertServiceExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", codespaceUUID) + hasReady, err := HasReadyRuntimeMetadata(t.Context(), codespaceUUID, 71) + require.NoError(t, err) + assert.False(t, hasReady) +} + +func TestReportRuntimeTransitionFailedFromStoppedAndIdempotent(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "92929292-9292-4929-8929-929292929292" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusStopped, + OperationRVersion: 72, + RuntimeGeneration: 2, + UpdatedUnix: 11, + }) + insertServiceCredentials(t, codespaceUUID) + + err := ReportRuntimeTransition(t.Context(), manager, ReportRuntimeTransitionOptions{ + CodespaceUUID: codespaceUUID, + RuntimeGeneration: 3, + ObservedOperationRVersion: 72, + RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED, + }) + require.NoError(t, err) + + row := loadServiceCodespace(t, codespaceUUID) + require.Equal(t, codespace_model.StatusFailed, row.Status) + require.EqualValues(t, 3, row.RuntimeGeneration) + firstUpdated := row.UpdatedUnix + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", codespaceUUID) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", codespaceUUID) + assert.Contains(t, readServiceLog(t, codespaceLogFilename(row.UUID)), "Gitea recorded runtime generation 3 as failed.") + + err = ReportRuntimeTransition(t.Context(), manager, ReportRuntimeTransitionOptions{ + CodespaceUUID: codespaceUUID, + RuntimeGeneration: 3, + ObservedOperationRVersion: 72, + RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED, + }) + require.NoError(t, err) + assert.Equal(t, firstUpdated, loadServiceCodespace(t, codespaceUUID).UpdatedUnix) +} + +func TestReportRuntimeTransitionAllowedWhenCodespaceDisabled(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + defer test.MockVariableValue(&setting.Codespace.Enabled, false)() + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "96969696-9696-4969-8969-969696969696" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 76, + RuntimeGeneration: 1, + }) + insertServiceCredentials(t, codespaceUUID) + + err := ReportRuntimeTransition(t.Context(), manager, ReportRuntimeTransitionOptions{ + CodespaceUUID: codespaceUUID, + RuntimeGeneration: 2, + ObservedOperationRVersion: 76, + RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED, + }) + require.NoError(t, err) + + row := loadServiceCodespace(t, codespaceUUID) + assert.Equal(t, codespace_model.StatusStopped, row.Status) + assert.EqualValues(t, 2, row.RuntimeGeneration) + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", codespaceUUID) + assertServiceExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", codespaceUUID) +} + +func TestReportRuntimeTransitionRejectsConflicts(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + activeUUID := "93939393-9393-4939-8939-939393939393" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: activeUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 73, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + RuntimeGeneration: 1, + }) + err := ReportRuntimeTransition(t.Context(), manager, ReportRuntimeTransitionOptions{ + CodespaceUUID: activeUUID, + RuntimeGeneration: 2, + ObservedOperationRVersion: 73, + RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED, + }) + require.ErrorIs(t, err, ErrRuntimeTransitionCurrentOperationConflict) + assert.Equal(t, codespace_model.StatusRunning, loadServiceCodespace(t, activeUUID).Status) + + staleUUID := "94949494-9494-4949-8949-949494949494" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: staleUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 74, + RuntimeGeneration: 5, + }) + err = ReportRuntimeTransition(t.Context(), manager, ReportRuntimeTransitionOptions{ + CodespaceUUID: staleUUID, + RuntimeGeneration: 4, + ObservedOperationRVersion: 74, + RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED, + }) + var stale *StaleGenerationError + require.ErrorAs(t, err, &stale) + assert.EqualValues(t, 5, stale.CurrentGeneration) + + err = ReportRuntimeTransition(t.Context(), manager, ReportRuntimeTransitionOptions{ + CodespaceUUID: staleUUID, + RuntimeGeneration: 5, + ObservedOperationRVersion: 74, + RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED, + }) + require.ErrorIs(t, err, ErrRuntimeTransitionGenerationConflict) + + err = ReportRuntimeTransition(t.Context(), manager, ReportRuntimeTransitionOptions{ + CodespaceUUID: staleUUID, + RuntimeGeneration: 6, + ObservedOperationRVersion: 73, + RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED, + }) + assert.ErrorIs(t, err, ErrRuntimeTransitionStaleOperation) +} diff --git a/services/codespace/session_revalidate.go b/services/codespace/session_revalidate.go new file mode 100644 index 0000000000000..3f24639f03ef4 --- /dev/null +++ b/services/codespace/session_revalidate.go @@ -0,0 +1,140 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + user_model "gitea.dev/models/user" + "gitea.dev/modules/setting" +) + +const ( + // SessionDeniedCodespaceNotFound means the Codespace no longer exists. + SessionDeniedCodespaceNotFound = "codespace_not_found" + // SessionDeniedCodespaceNotRunning means the Codespace is not running. + SessionDeniedCodespaceNotRunning = "codespace_not_running" + // SessionDeniedManagerMismatch means the Codespace is bound to another Manager. + SessionDeniedManagerMismatch = "manager_mismatch" + // SessionDeniedPermissionDenied means the session user does not match the Codespace creator. + SessionDeniedPermissionDenied = "permission_denied" + // SessionDeniedLoginRestricted means the Codespace creator cannot currently log in. + SessionDeniedLoginRestricted = "login_restricted" + // SessionDeniedStateUnavailable means the lifecycle state cannot keep the session. + SessionDeniedStateUnavailable = "state_unavailable" + // SessionDeniedMetadataRebuilding means Runtime Metadata is absent or not ready. + SessionDeniedMetadataRebuilding = "metadata_rebuilding" + // SessionDeniedEndpointNotFound means the authenticated Endpoint binding is no longer private. + SessionDeniedEndpointNotFound = "endpoint_not_found" +) + +// RevalidateGatewaySession checks whether an existing Gateway session remains authorized. +func RevalidateGatewaySession(ctx context.Context, manager *codespace_model.Manager, request *codespacev1.RevalidateGatewaySessionRequest) (*codespacev1.RevalidateGatewaySessionResponse, error) { + if manager == nil || manager.ID <= 0 { + return nil, errors.New("manager is required") + } + if !setting.Codespace.Enabled { + return denyGatewaySession(SessionDeniedStateUnavailable), nil + } + userID, codespaceUUID, endpointID, sshSession, err := validateGatewaySessionBinding(request) + if err != nil { + return nil, err + } + + currentManager, err := loadCodespaceManager(ctx, manager.ID) + if err != nil { + return nil, err + } + if currentManager.RuntimeState != codespace_model.ManagerRuntimeStateOnline || isManagerOffline(currentManager) { + return denyGatewaySession(SessionDeniedStateUnavailable), nil + } + + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(codespaceUUID).Get(codespace) + if err != nil { + return nil, err + } + if !has { + return denyGatewaySession(SessionDeniedCodespaceNotFound), nil + } + if codespace.ManagerID != manager.ID { + return denyGatewaySession(SessionDeniedManagerMismatch), nil + } + if userID != codespace.UserID { + return denyGatewaySession(SessionDeniedPermissionDenied), nil + } + if codespace.Status != codespace_model.StatusRunning { + return denyGatewaySession(SessionDeniedCodespaceNotRunning), nil + } + if hasActiveOperation(codespace) { + return denyGatewaySession(SessionDeniedStateUnavailable), nil + } + + user, err := user_model.GetUserByID(ctx, codespace.UserID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + return denyGatewaySession(SessionDeniedLoginRestricted), nil + } + return nil, err + } + canUseGateway, err := codespaceUserCanLogIn(ctx, user) + if err != nil { + return nil, err + } + if !canUseGateway { + return denyGatewaySession(SessionDeniedLoginRestricted), nil + } + + entry, hasEntry, err := getRuntimeMetadataEntry(codespaceUUID) + if err != nil { + return nil, err + } + if !hasEntry || !runtimeMetadataReadyForRunning(codespace, entry.Metadata) { + return denyGatewaySession(SessionDeniedMetadataRebuilding), nil + } + + endpoint, endpointFound := entry.Metadata.endpointByID(endpointID) + if sshSession || (endpointFound && !endpoint.Public) { + return &codespacev1.RevalidateGatewaySessionResponse{ + Outcome: &codespacev1.RevalidateGatewaySessionResponse_Allowed{Allowed: &codespacev1.SessionAllowed{}}, + }, nil + } + return denyGatewaySession(SessionDeniedEndpointNotFound), nil +} + +func validateGatewaySessionBinding(request *codespacev1.RevalidateGatewaySessionRequest) (userID int64, codespaceUUID, endpointID string, sshSession bool, err error) { + if endpoint := request.GetEndpoint(); endpoint != nil { + userID = endpoint.GetUserId() + codespaceUUID = endpoint.GetCodespaceUuid() + endpointID = endpoint.GetEndpointId() + if endpointID != workspaceEndpointID && !endpointIDPattern.MatchString(endpointID) { + return 0, "", "", false, errors.New("invalid endpoint_id") + } + } else if ssh := request.GetSsh(); ssh != nil { + userID = ssh.GetUserId() + codespaceUUID = ssh.GetCodespaceUuid() + sshSession = true + } else { + return 0, "", "", false, errors.New("session is required") + } + if userID <= 0 { + return 0, "", "", false, errors.New("user_id must be positive") + } + if err := codespace_model.ValidateUUID(codespaceUUID); err != nil { + return 0, "", "", false, err + } + return userID, codespaceUUID, endpointID, sshSession, nil +} + +func denyGatewaySession(category string) *codespacev1.RevalidateGatewaySessionResponse { + return &codespacev1.RevalidateGatewaySessionResponse{ + Outcome: &codespacev1.RevalidateGatewaySessionResponse_Denied{ + Denied: &codespacev1.FailureDetail{Category: category}, + }, + } +} diff --git a/services/codespace/session_revalidate_test.go b/services/codespace/session_revalidate_test.go new file mode 100644 index 0000000000000..9629394e1d36e --- /dev/null +++ b/services/codespace/session_revalidate_test.go @@ -0,0 +1,114 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRevalidateGatewaySessionAllowsPrivateEndpointAndSSH(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "81818181-8181-4818-8818-818181818181" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 61, + InteractionGeneration: 7, + LastActiveUnix: 12, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 61, []map[string]any{ + {"endpoint_id": "app-3000", "label": "App", "public": false}, + {"endpoint_id": "public-api", "label": "API", "public": true}, + }))) + + endpoint, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ + Session: &codespacev1.RevalidateGatewaySessionRequest_Endpoint{Endpoint: &codespacev1.EndpointSessionBinding{ + UserId: 1, CodespaceUuid: codespaceUUID, EndpointId: "app-3000", + }}, + }) + require.NoError(t, err) + assert.NotNil(t, endpoint.GetAllowed()) + + workspace, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ + Session: &codespacev1.RevalidateGatewaySessionRequest_Endpoint{Endpoint: &codespacev1.EndpointSessionBinding{ + UserId: 1, CodespaceUuid: codespaceUUID, EndpointId: "workspace", + }}, + }) + require.NoError(t, err) + assert.NotNil(t, workspace.GetAllowed()) + + sshSession, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ + Session: &codespacev1.RevalidateGatewaySessionRequest_Ssh{Ssh: &codespacev1.SSHSessionBinding{ + UserId: 1, CodespaceUuid: codespaceUUID, + }}, + }) + require.NoError(t, err) + assert.NotNil(t, sshSession.GetAllowed()) + + row := loadServiceCodespace(t, codespaceUUID) + assert.EqualValues(t, 7, row.InteractionGeneration) + assert.EqualValues(t, 12, row.LastActiveUnix) +} + +func TestRevalidateGatewaySessionDeniesChangedEndpointAndState(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "82828282-8282-4828-8828-828282828282" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 62, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 62, []map[string]any{ + {"endpoint_id": "public-api", "label": "API", "public": true}, + }))) + + publicEndpoint, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ + Session: &codespacev1.RevalidateGatewaySessionRequest_Endpoint{Endpoint: &codespacev1.EndpointSessionBinding{ + UserId: 1, CodespaceUuid: codespaceUUID, EndpointId: "public-api", + }}, + }) + require.NoError(t, err) + assert.Equal(t, SessionDeniedEndpointNotFound, publicEndpoint.GetDenied().GetCategory()) + + wrongUser, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ + Session: &codespacev1.RevalidateGatewaySessionRequest_Ssh{Ssh: &codespacev1.SSHSessionBinding{ + UserId: 2, CodespaceUuid: codespaceUUID, + }}, + }) + require.NoError(t, err) + assert.Equal(t, SessionDeniedPermissionDenied, wrongUser.GetDenied().GetCategory()) + + activeUUID := "83838383-8383-4838-8838-838383838383" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: activeUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 63, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerIdle, + OperationCreatedUnix: time.Now().Unix(), + }) + require.NoError(t, putRuntimeMetadataEntry(activeUUID, serviceRuntimeMetadataEntry(t, 63, []map[string]any{}))) + active, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ + Session: &codespacev1.RevalidateGatewaySessionRequest_Ssh{Ssh: &codespacev1.SSHSessionBinding{ + UserId: 1, CodespaceUuid: activeUUID, + }}, + }) + require.NoError(t, err) + assert.Equal(t, SessionDeniedStateUnavailable, active.GetDenied().GetCategory()) +} diff --git a/services/codespace/settings.go b/services/codespace/settings.go new file mode 100644 index 0000000000000..db128e5c8c894 --- /dev/null +++ b/services/codespace/settings.go @@ -0,0 +1,581 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "strings" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + user_model "gitea.dev/models/user" + "gitea.dev/modules/globallock" + "gitea.dev/modules/util" +) + +const ( + // ManagerSettingsScopeSite selects the site-wide Codespace settings view. + ManagerSettingsScopeSite = "site" + // ManagerSettingsScopeUser selects one user's Codespace settings view. + ManagerSettingsScopeUser = "user" +) + +var ( + // ErrManagerSettingsNotFound is returned when a Manager is outside the requested scope. + ErrManagerSettingsNotFound = errors.New("codespace manager settings target not found") + // ErrManagerSettingsConfirmRequired is returned when a destructive settings action lacks confirmation. + ErrManagerSettingsConfirmRequired = errors.New("codespace manager settings confirmation required") + // ErrManagerSettingsOwnershipConflict is returned before personal deletion when a binding crosses the owner scope. + ErrManagerSettingsOwnershipConflict = errors.New("codespace manager contains a Codespace outside the owner scope") +) + +// ManagerSettingsOptions selects site-wide or personal Codespace settings. +type ManagerSettingsOptions struct { + Scope string + UserID int64 +} + +// DeleteManagerOptions identifies one Manager deletion request. +type DeleteManagerOptions struct { + Scope string + UserID int64 + ManagerID int64 + Confirm bool +} + +// ManagerDetailOptions selects one Manager management page and its Codespace page. +type ManagerDetailOptions struct { + ManagerSettingsOptions + ManagerID int64 + Page int + PageSize int +} + +// ManagerDetail contains one Manager and its scoped Codespace governance rows. +type ManagerDetail struct { + Manager *ManagerSettingsView + Codespaces []*GovernanceView + Total int64 +} + +// ManagerSettings contains registration token and Manager rows for settings pages. +type ManagerSettings struct { + RegistrationToken string + Managers []*ManagerSettingsView +} + +// ManagerSettingsView contains fields shown on Manager settings pages. +type ManagerSettingsView struct { + ID int64 + Name string + UserID int64 + UserDisplayName string + Version string + RuntimeDisplayState string + Environments []ManagerEnvironmentDeclaration + EnvironmentDescriptionConflicts []string + LastOnlineUnix int64 + CreatedUnix int64 + GatewayURL string + GatewaySSHAddr string + GatewaySSHHostKeyAlgorithm string + GatewaySSHHostKeyFingerprintSHA256 string + GatewaySSHHostKeyUpdatedUnix int64 + BoundCodespaces int64 +} + +// ListManagerSettings returns the current token row and Manager summaries for one settings page. +func ListManagerSettings(ctx context.Context, opts ManagerSettingsOptions) (*ManagerSettings, error) { + if err := validateManagerSettingsScope(ctx, opts); err != nil { + return nil, err + } + result := &ManagerSettings{} + token, err := GetOrCreateRegistrationToken(ctx, opts) + if err != nil { + return nil, err + } + result.RegistrationToken = token + managers, err := listSettingsManagers(ctx, opts) + if err != nil { + return nil, err + } + result.Managers = managers + return result, nil +} + +// GetManagerDetail returns one Manager only when it belongs to the requested settings scope. +func GetManagerDetail(ctx context.Context, opts ManagerDetailOptions) (*ManagerDetail, error) { + if err := validateManagerSettingsScope(ctx, opts.ManagerSettingsOptions); err != nil { + return nil, err + } + if opts.ManagerID <= 0 || opts.Page <= 0 || opts.PageSize <= 0 { + return nil, ErrManagerSettingsNotFound + } + manager, err := loadSettingsManager(ctx, opts.ManagerID) + if err != nil { + return nil, err + } + if manager == nil || !managerInSettingsScope(manager, opts.Scope, opts.UserID) { + return nil, ErrManagerSettingsNotFound + } + views, err := settingsManagerViews(ctx, []*codespace_model.Manager{manager}, opts.UserID) + if err != nil { + return nil, err + } + views[0].EnvironmentDescriptionConflicts, err = findEnvironmentDescriptionConflicts(ctx, views[0].Environments, opts.ManagerSettingsOptions) + if err != nil { + return nil, err + } + list, err := ListGovernanceCodespaces(ctx, GovernanceListOptions{ + ManagerID: manager.ID, + UserID: opts.UserID, + Page: opts.Page, + PageSize: opts.PageSize, + }) + if err != nil { + return nil, err + } + return &ManagerDetail{Manager: views[0], Codespaces: list.Rows, Total: list.Total}, nil +} + +// GetOrCreateRegistrationToken returns or creates the current site or personal token. +func GetOrCreateRegistrationToken(ctx context.Context, opts ManagerSettingsOptions) (string, error) { + if err := validateManagerSettingsScope(ctx, opts); err != nil { + return "", err + } + userID := registrationTokenUserID(opts) + var tokenValue string + err := globallock.LockAndDo(ctx, codespaceUserRelationLockKey(userID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + if err := validateManagerSettingsUserInTx(ctx, opts); err != nil { + return err + } + token, err := loadRegistrationTokenByUser(ctx, userID) + if err != nil { + return err + } + if token != nil { + tokenValue = token.Token + return nil + } + tokenValue = newRegistrationToken() + _, err = db.GetEngine(ctx).Insert(&codespace_model.ManagerToken{ + UserID: userID, + Token: tokenValue, + }) + return err + }) + }) + return tokenValue, err +} + +// ResetRegistrationToken replaces the current site or personal token in place. +func ResetRegistrationToken(ctx context.Context, opts ManagerSettingsOptions) (string, error) { + if err := validateManagerSettingsScope(ctx, opts); err != nil { + return "", err + } + userID := registrationTokenUserID(opts) + var tokenValue string + err := globallock.LockAndDo(ctx, codespaceUserRelationLockKey(userID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + if err := validateManagerSettingsUserInTx(ctx, opts); err != nil { + return err + } + tokenValue = newRegistrationToken() + token, err := loadRegistrationTokenByUser(ctx, userID) + if err != nil { + return err + } + if token == nil { + _, err = db.GetEngine(ctx).Insert(&codespace_model.ManagerToken{ + UserID: userID, + Token: tokenValue, + }) + return err + } + token.Token = tokenValue + _, err = db.GetEngine(ctx).ID(token.ID).Cols("token").Update(token) + return err + }) + }) + return tokenValue, err +} + +// DeleteManager removes one Manager identity and all Gitea records bound to it. +func DeleteManager(ctx context.Context, opts DeleteManagerOptions) error { + if !opts.Confirm { + return ErrManagerSettingsConfirmRequired + } + if opts.ManagerID <= 0 { + return ErrManagerSettingsNotFound + } + manager, err := loadSettingsManager(ctx, opts.ManagerID) + if err != nil { + return err + } + if manager == nil || !managerInSettingsScope(manager, opts.Scope, opts.UserID) { + return ErrManagerSettingsNotFound + } + return globallock.LockAndDo(ctx, codespaceUserRelationLockKey(manager.UserID), func(ctx context.Context) error { + return deleteManagerIdentityLocked(ctx, manager.ID, 100, func(current *codespace_model.Manager) (bool, error) { + if !managerInSettingsScope(current, opts.Scope, opts.UserID) { + return false, ErrManagerSettingsNotFound + } + if opts.Scope == ManagerSettingsScopeUser { + foreignBinding, err := db.GetEngine(ctx). + Where("manager_id = ? AND user_id <> ?", current.ID, opts.UserID). + Exist(new(codespace_model.Codespace)) + if err != nil { + return false, err + } + if foreignBinding { + return false, ErrManagerSettingsOwnershipConflict + } + } + return true, nil + }) + }) +} + +func deleteManagerIdentityLocked(ctx context.Context, managerID int64, batchSize int, validate func(*codespace_model.Manager) (bool, error)) error { + return globallock.LockAndDo(ctx, fetchManagerLockKey(managerID), func(ctx context.Context) error { + if validate != nil { + current, err := loadSettingsManager(ctx, managerID) + if err != nil { + return err + } + if current == nil { + return nil + } + ok, err := validate(current) + if err != nil || !ok { + return err + } + } + for { + codespaceUUIDs, err := listManagerCodespaceUUIDs(ctx, managerID, batchSize) + if err != nil { + return err + } + if len(codespaceUUIDs) == 0 { + break + } + for _, codespaceUUID := range codespaceUUIDs { + if err := deleteManagerCodespace(ctx, managerID, codespaceUUID); err != nil { + return err + } + } + } + return db.WithTx(ctx, func(ctx context.Context) error { + current, err := loadSettingsManager(ctx, managerID) + if err != nil { + return err + } + if current == nil { + return nil + } + if validate != nil { + ok, err := validate(current) + if err != nil || !ok { + return err + } + } + hasCodespace, err := db.GetEngine(ctx).Where("manager_id = ?", current.ID).Exist(new(codespace_model.Codespace)) + if err != nil { + return err + } + if hasCodespace { + return fmt.Errorf("manager %d still has bound codespaces", current.ID) + } + if _, err := db.GetEngine(ctx).Where("manager_id = ?", current.ID).Delete(new(codespace_model.ManagerAddress)); err != nil { + return err + } + _, err = db.GetEngine(ctx).ID(current.ID).Delete(new(codespace_model.Manager)) + return err + }) + }) +} + +func listSettingsManagers(ctx context.Context, opts ManagerSettingsOptions) ([]*ManagerSettingsView, error) { + var managers []*codespace_model.Manager + query := db.GetEngine(ctx) + if opts.Scope != ManagerSettingsScopeSite { + query = query.Where("user_id = ?", opts.UserID) + } + if err := query.Asc("user_id", "id").Find(&managers); err != nil { + return nil, err + } + return settingsManagerViews(ctx, managers, opts.UserID) +} + +func settingsManagerViews(ctx context.Context, managers []*codespace_model.Manager, scopeUserID int64) ([]*ManagerSettingsView, error) { + result := make([]*ManagerSettingsView, 0, len(managers)) + if len(managers) == 0 { + return result, nil + } + + managerIDs := make([]int64, 0, len(managers)) + userIDs := make([]int64, 0, len(managers)) + seenUserIDs := make(map[int64]struct{}, len(managers)) + for _, manager := range managers { + managerIDs = append(managerIDs, manager.ID) + if manager.UserID > 0 { + if _, seen := seenUserIDs[manager.UserID]; !seen { + seenUserIDs[manager.UserID] = struct{}{} + userIDs = append(userIDs, manager.UserID) + } + } + } + users, err := user_model.GetUsersMapByIDs(ctx, userIDs) + if err != nil { + return nil, err + } + + type managerAddresses struct { + gatewayURL string + gatewaySSHAddr string + } + addressesByManager := make(map[int64]managerAddresses, len(managers)) + boundCodespacesByManager := make(map[int64]int64, len(managers)) + for start := 0; start < len(managerIDs); start += db.DefaultMaxInSize { + end := min(start+db.DefaultMaxInSize, len(managerIDs)) + ids := managerIDs[start:end] + + var addresses []*codespace_model.ManagerAddress + if err := db.GetEngine(ctx).In("manager_id", ids).Asc("id").Find(&addresses); err != nil { + return nil, err + } + for _, address := range addresses { + values := addressesByManager[address.ManagerID] + switch address.Kind { + case codespace_model.ManagerAddressGateway: + values.gatewayURL = address.Address + case codespace_model.ManagerAddressSSH: + values.gatewaySSHAddr = address.Address + } + addressesByManager[address.ManagerID] = values + } + + counts := make([]*struct { + ManagerID int64 + Count int64 + }, 0, len(ids)) + query := db.GetEngine(ctx).In("manager_id", ids) + if scopeUserID > 0 { + query = query.Where("user_id = ?", scopeUserID) + } + if err := query. + Select("manager_id AS manager_id, COUNT(*) AS count"). + Table("codespace"). + GroupBy("manager_id"). + Find(&counts); err != nil { + return nil, err + } + for _, count := range counts { + boundCodespacesByManager[count.ManagerID] = count.Count + } + } + + for _, manager := range managers { + environments, err := decodeManagerEnvironments(manager) + if err != nil { + return nil, err + } + userName := "Global" + if manager.UserID > 0 { + if user := users[manager.UserID]; user != nil { + userName = user.DisplayName() + } else { + userName = fmt.Sprintf("User %d", manager.UserID) + } + } + runtimeState := manager.RuntimeState + if runtimeState == "" { + runtimeState = codespace_model.ManagerRuntimeStateRecovering + } + addresses := addressesByManager[manager.ID] + view := &ManagerSettingsView{ + ID: manager.ID, + Name: manager.Name, + UserID: manager.UserID, + UserDisplayName: userName, + Version: manager.Version, + RuntimeDisplayState: runtimeState, + Environments: environments, + LastOnlineUnix: manager.LastOnlineUnix, + CreatedUnix: manager.CreatedUnix, + GatewayURL: addresses.gatewayURL, + GatewaySSHAddr: addresses.gatewaySSHAddr, + GatewaySSHHostKeyAlgorithm: manager.GatewaySSHHostKeyAlgorithm, + GatewaySSHHostKeyFingerprintSHA256: manager.GatewaySSHHostKeyFingerprintSHA256, + GatewaySSHHostKeyUpdatedUnix: manager.GatewaySSHHostKeyUpdatedUnix, + BoundCodespaces: boundCodespacesByManager[manager.ID], + } + if isManagerOffline(manager) { + view.RuntimeDisplayState = managerDisplayOffline + } + if view.Name == "" { + view.Name = fmt.Sprintf("Manager %d", manager.ID) + } + result = append(result, view) + } + return result, nil +} + +func findEnvironmentDescriptionConflicts(ctx context.Context, targetEnvironments []ManagerEnvironmentDeclaration, opts ManagerSettingsOptions) ([]string, error) { + var managers []*codespace_model.Manager + query := db.GetEngine(ctx).Where("last_online_unix > 0") + if opts.Scope == ManagerSettingsScopeUser { + query = query.In("user_id", []int64{0, opts.UserID}) + } + if err := query.Find(&managers); err != nil { + return nil, err + } + + descriptions := make(map[string]map[string]struct{}) + for _, manager := range managers { + environments, err := decodeManagerEnvironments(manager) + if err != nil { + return nil, err + } + for _, environment := range environments { + if environment.Description == "" { + continue + } + if descriptions[environment.Tag] == nil { + descriptions[environment.Tag] = make(map[string]struct{}) + } + descriptions[environment.Tag][environment.Description] = struct{}{} + } + } + + conflicts := make([]string, 0) + for _, environment := range targetEnvironments { + if len(descriptions[environment.Tag]) > 1 { + conflicts = append(conflicts, environment.Tag) + } + } + return conflicts, nil +} + +func loadRegistrationTokenByUser(ctx context.Context, userID int64) (*codespace_model.ManagerToken, error) { + token := new(codespace_model.ManagerToken) + has, err := db.GetEngine(ctx).Where("user_id = ?", userID).Get(token) + if err != nil || !has { + return nil, err + } + return token, nil +} + +func registrationTokenUserID(opts ManagerSettingsOptions) int64 { + if opts.Scope == ManagerSettingsScopeSite { + return 0 + } + return opts.UserID +} + +func validateManagerSettingsScope(ctx context.Context, opts ManagerSettingsOptions) error { + switch opts.Scope { + case ManagerSettingsScopeSite: + if opts.UserID != 0 { + return errors.New("site settings user_id must be 0") + } + return nil + case ManagerSettingsScopeUser: + if opts.UserID <= 0 { + return errors.New("user_id must be positive") + } + return validateManagerSettingsUser(ctx, opts) + default: + return fmt.Errorf("unsupported manager settings scope %q", opts.Scope) + } +} + +func validateManagerSettingsUser(ctx context.Context, opts ManagerSettingsOptions) error { + user, err := user_model.GetUserByID(ctx, opts.UserID) + if err != nil { + return err + } + return validateManagerSettingsUserType(user) +} + +func validateManagerSettingsUserInTx(ctx context.Context, opts ManagerSettingsOptions) error { + if opts.Scope == ManagerSettingsScopeSite { + return nil + } + user, err := user_model.GetUserByID(ctx, opts.UserID) + if err != nil { + return err + } + return validateManagerSettingsUserType(user) +} + +func validateManagerSettingsUserType(user *user_model.User) error { + if user == nil { + return errors.New("user is required") + } + if user.Type != user_model.UserTypeIndividual { + return errors.New("user is not an individual") + } + return nil +} + +func loadSettingsManager(ctx context.Context, managerID int64) (*codespace_model.Manager, error) { + manager := new(codespace_model.Manager) + has, err := db.GetEngine(ctx).ID(managerID).Get(manager) + if err != nil || !has { + return nil, err + } + return manager, nil +} + +func managerInSettingsScope(manager *codespace_model.Manager, scope string, userID int64) bool { + if manager == nil { + return false + } + switch scope { + case ManagerSettingsScopeSite: + return true + case ManagerSettingsScopeUser: + return manager.UserID == userID + default: + return false + } +} + +func listManagerCodespaceUUIDs(ctx context.Context, managerID int64, limit int) ([]string, error) { + var rows []*codespace_model.Codespace + if err := db.GetEngine(ctx). + Where("manager_id = ?", managerID). + Asc("uuid"). + Limit(limit). + Find(&rows); err != nil { + return nil, err + } + result := make([]string, 0, len(rows)) + for _, row := range rows { + result = append(result, row.UUID) + } + return result, nil +} + +func deleteManagerCodespace(ctx context.Context, managerID int64, codespaceUUID string) error { + return globallock.LockAndDo(ctx, codespaceStateLockKey(codespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(codespaceUUID).Get(codespace) + if err != nil || !has || codespace.ManagerID != managerID { + return err + } + return deleteCodespaceForFinal(ctx, codespaceUUID) + }) + }) +} + +func newRegistrationToken() string { + return strings.ToLower(hex.EncodeToString(util.CryptoRandomBytes(32))) +} diff --git a/services/codespace/settings_test.go b/services/codespace/settings_test.go new file mode 100644 index 0000000000000..4638f7ea73706 --- /dev/null +++ b/services/codespace/settings_test.go @@ -0,0 +1,231 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegistrationTokenSettingsLifecycle(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + opts := ManagerSettingsOptions{Scope: ManagerSettingsScopeUser, UserID: 1} + settings, err := ListManagerSettings(t.Context(), opts) + require.NoError(t, err) + require.Len(t, settings.RegistrationToken, 64) + + token, err := GetOrCreateRegistrationToken(t.Context(), opts) + require.NoError(t, err) + assert.Equal(t, settings.RegistrationToken, token) + + sameToken, err := GetOrCreateRegistrationToken(t.Context(), opts) + require.NoError(t, err) + assert.Equal(t, token, sameToken) + + resetToken, err := ResetRegistrationToken(t.Context(), opts) + require.NoError(t, err) + require.Len(t, resetToken, 64) + assert.NotEqual(t, token, resetToken) + + assertServiceNotExists(t, new(codespace_model.ManagerToken), "token = ?", token) + assertServiceExists(t, new(codespace_model.ManagerToken), "user_id = ? AND token = ?", 1, resetToken) +} + +func TestRegisterManagerUsesCurrentTokenAndKeepsSecretAfterReset(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + opts := ManagerSettingsOptions{Scope: ManagerSettingsScopeSite} + token, err := GetOrCreateRegistrationToken(t.Context(), opts) + require.NoError(t, err) + + manager, secret, err := RegisterManager(t.Context(), token) + require.NoError(t, err) + assert.EqualValues(t, 0, manager.UserID) + _, err = AuthenticateManager(t.Context(), manager.ID, secret) + require.NoError(t, err) + + resetToken, err := ResetRegistrationToken(t.Context(), opts) + require.NoError(t, err) + _, err = AuthenticateManager(t.Context(), manager.ID, secret) + require.NoError(t, err) + _, _, err = RegisterManager(t.Context(), token) + require.Error(t, err) + + resetManager, _, err := RegisterManager(t.Context(), resetToken) + require.NoError(t, err) + assert.NotEqual(t, manager.ID, resetManager.ID) +} + +func TestListManagerSettingsScopesAndDeleteManager(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + userToken, err := GetOrCreateRegistrationToken(t.Context(), ManagerSettingsOptions{ + Scope: ManagerSettingsScopeUser, + UserID: 1, + }) + require.NoError(t, err) + globalManager := insertServiceManager(t) + globalManager.TagsJSON = `[{"tag":"default","description":"Site environment"}]` + _, err = db.GetEngine(t.Context()).ID(globalManager.ID).Cols("tags_json").Update(globalManager) + require.NoError(t, err) + userManager := insertServiceManager(t) + userManager.UserID = 1 + userManager.Name = "user-manager" + userManager.Version = "0.2.0" + userManager.TagsJSON = `[{"tag":"default","description":"Personal environment"},{"tag":"gpu"}]` + userManager.GatewaySSHHostKeyAlgorithm = "ssh-ed25519" + userManager.GatewaySSHHostKeyFingerprintSHA256 = "SHA256:settings" + userManager.GatewaySSHHostKeyUpdatedUnix = 123 + _, err = db.GetEngine(t.Context()).ID(userManager.ID).Cols( + "user_id", "name", "version", "tags_json", "gateway_ssh_host_key_algorithm", + "gateway_ssh_host_key_fingerprint_sha256", "gateway_ssh_host_key_updated_unix", + ).Update(userManager) + require.NoError(t, err) + insertSettingsManagerAddress(t, globalManager.ID, codespace_model.ManagerAddressGateway, "https://global-gateway.example.com") + insertSettingsManagerAddress(t, userManager.ID, codespace_model.ManagerAddressGateway, "https://user-gateway.example.com") + insertSettingsManagerAddress(t, userManager.ID, codespace_model.ManagerAddressSSH, "ssh.example.com:2222") + + codespaceUUID := "51515151-5151-4151-8151-515151515151" + insertServiceCodespace(t, userManager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 51, + }) + require.NoError(t, db.Insert(t.Context(), &codespace_model.GiteaToken{ + CodespaceUUID: codespaceUUID, + TokenHash: "manager-delete-hash", + TokenSalt: "salt", + TokenLastEight: "last0001", + TokenEncrypted: "encrypted", + })) + require.NoError(t, db.Insert(t.Context(), &codespace_model.SSHKey{ + CodespaceUUID: codespaceUUID, + KeyID: 5151, + })) + + siteSettings, err := ListManagerSettings(t.Context(), ManagerSettingsOptions{Scope: ManagerSettingsScopeSite}) + require.NoError(t, err) + assert.Len(t, siteSettings.Managers, 2) + + userSettings, err := ListManagerSettings(t.Context(), ManagerSettingsOptions{ + Scope: ManagerSettingsScopeUser, + UserID: 1, + }) + require.NoError(t, err) + require.Len(t, userSettings.Managers, 1) + assert.Equal(t, userManager.ID, userSettings.Managers[0].ID) + assert.EqualValues(t, 1, userSettings.Managers[0].BoundCodespaces) + assert.Equal(t, "https://user-gateway.example.com", userSettings.Managers[0].GatewayURL) + assert.Equal(t, "0.2.0", userSettings.Managers[0].Version) + assert.Equal(t, []ManagerEnvironmentDeclaration{{Tag: "default", Description: "Personal environment"}, {Tag: "gpu"}}, userSettings.Managers[0].Environments) + assert.Equal(t, "ssh-ed25519", userSettings.Managers[0].GatewaySSHHostKeyAlgorithm) + assert.Equal(t, "SHA256:settings", userSettings.Managers[0].GatewaySSHHostKeyFingerprintSHA256) + assert.EqualValues(t, 123, userSettings.Managers[0].GatewaySSHHostKeyUpdatedUnix) + detail, err := GetManagerDetail(t.Context(), ManagerDetailOptions{ + ManagerSettingsOptions: ManagerSettingsOptions{Scope: ManagerSettingsScopeUser, UserID: 1}, + ManagerID: userManager.ID, + Page: 1, + PageSize: 30, + }) + require.NoError(t, err) + assert.Equal(t, []ManagerEnvironmentDeclaration{{Tag: "default", Description: "Personal environment"}, {Tag: "gpu"}}, detail.Manager.Environments) + assert.Equal(t, []string{"default"}, detail.Manager.EnvironmentDescriptionConflicts) + require.Len(t, detail.Codespaces, 1) + assert.Equal(t, codespaceUUID, detail.Codespaces[0].UUID) + assert.EqualValues(t, 1, detail.Total) + _, err = GetManagerDetail(t.Context(), ManagerDetailOptions{ + ManagerSettingsOptions: ManagerSettingsOptions{Scope: ManagerSettingsScopeUser, UserID: 2}, + ManagerID: userManager.ID, + Page: 1, + PageSize: 30, + }) + require.ErrorIs(t, err, ErrManagerSettingsNotFound) + + err = DeleteManager(t.Context(), DeleteManagerOptions{ + Scope: ManagerSettingsScopeUser, + UserID: 1, + ManagerID: globalManager.ID, + Confirm: true, + }) + require.ErrorIs(t, err, ErrManagerSettingsNotFound) + err = DeleteManager(t.Context(), DeleteManagerOptions{ + Scope: ManagerSettingsScopeUser, + UserID: 1, + ManagerID: userManager.ID, + }) + require.ErrorIs(t, err, ErrManagerSettingsConfirmRequired) + + require.NoError(t, DeleteManager(t.Context(), DeleteManagerOptions{ + Scope: ManagerSettingsScopeUser, + UserID: 1, + ManagerID: userManager.ID, + Confirm: true, + })) + assertServiceNotExists(t, new(codespace_model.Manager), "id = ?", userManager.ID) + assertServiceNotExists(t, new(codespace_model.ManagerAddress), "manager_id = ?", userManager.ID) + assertServiceNotExists(t, new(codespace_model.Codespace), "uuid = ?", codespaceUUID) + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", codespaceUUID) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_uuid = ?", codespaceUUID) + assertServiceExists(t, new(codespace_model.ManagerToken), "user_id = ? AND token = ?", 1, userToken) +} + +func TestPersonalManagerDeleteRejectsForeignBindingBeforeCleanup(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + manager.UserID = 1 + _, err := db.GetEngine(t.Context()).ID(manager.ID).Cols("user_id").Update(manager) + require.NoError(t, err) + codespaceUUID := "52525252-5252-4252-8252-525252525252" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{UUID: codespaceUUID, Status: codespace_model.StatusStopped}) + _, err = db.GetEngine(t.Context()).ID(codespaceUUID).Cols("user_id").Update(&codespace_model.Codespace{UserID: 2}) + require.NoError(t, err) + + err = DeleteManager(t.Context(), DeleteManagerOptions{ + Scope: ManagerSettingsScopeUser, UserID: 1, ManagerID: manager.ID, Confirm: true, + }) + require.ErrorIs(t, err, ErrManagerSettingsOwnershipConflict) + assertServiceExists(t, new(codespace_model.Manager), "id = ?", manager.ID) + assertServiceExists(t, new(codespace_model.Codespace), "uuid = ?", codespaceUUID) +} + +func TestManagerSettingsRequireIndividualUser(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + _, err := GetOrCreateRegistrationToken(t.Context(), ManagerSettingsOptions{ + Scope: ManagerSettingsScopeUser, + UserID: 3, + }) + require.ErrorContains(t, err, "not an individual") +} + +func TestRegisterManagerRejectsOrganizationToken(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + token := &codespace_model.ManagerToken{ + Token: "organization-registration-token", + UserID: 3, + } + require.NoError(t, db.Insert(t.Context(), token)) + + _, _, err := RegisterManager(t.Context(), token.Token) + require.ErrorIs(t, err, ErrRegistrationUnauthenticated) + assertServiceNotExists(t, new(codespace_model.Manager), "user_id = ?", token.UserID) +} + +func insertSettingsManagerAddress(t *testing.T, managerID int64, kind, address string) { + t.Helper() + require.NoError(t, db.Insert(t.Context(), &codespace_model.ManagerAddress{ + ManagerID: managerID, + Kind: kind, + Address: address, + })) +} diff --git a/services/codespace/ssh_auth.go b/services/codespace/ssh_auth.go new file mode 100644 index 0000000000000..cbaa426d21af4 --- /dev/null +++ b/services/codespace/ssh_auth.go @@ -0,0 +1,216 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "bytes" + "context" + "errors" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + asymkey_model "gitea.dev/models/asymkey" + auth_model "gitea.dev/models/auth" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + user_model "gitea.dev/models/user" + "gitea.dev/modules/globallock" + "gitea.dev/modules/setting" + + "golang.org/x/crypto/ssh" +) + +const ( + // SSHAuthDeniedInvalidCredentials means the submitted public key is not an allowed user key. + SSHAuthDeniedInvalidCredentials = "invalid_credentials" + // SSHAuthDeniedLoginRestricted means the Codespace creator cannot currently log in. + SSHAuthDeniedLoginRestricted = "login_restricted" + // SSHAuthDeniedCodespaceNotFound means the Codespace no longer exists. + SSHAuthDeniedCodespaceNotFound = "codespace_not_found" + // SSHAuthDeniedCodespaceNotRunning means the Codespace is not running. + SSHAuthDeniedCodespaceNotRunning = "codespace_not_running" + // SSHAuthDeniedManagerMismatch means the Codespace is bound to another Manager. + SSHAuthDeniedManagerMismatch = "manager_mismatch" + // SSHAuthDeniedStateUnavailable means the lifecycle state cannot accept a new SSH connection. + SSHAuthDeniedStateUnavailable = "state_unavailable" + // SSHAuthDeniedMetadataRebuilding means Runtime Metadata is absent or not ready for SSH. + SSHAuthDeniedMetadataRebuilding = "metadata_rebuilding" + // SSHAuthDeniedVersionExhausted means interaction_generation cannot advance. + SSHAuthDeniedVersionExhausted = "version_exhausted" +) + +// VerifySSHPublicKeyOptions contains one Gateway SSH public key authentication request. +type VerifySSHPublicKeyOptions struct { + CodespaceUUID string + PublicKey []byte +} + +type normalizedUserSSHKey struct { + Wire []byte + Fingerprint string +} + +// VerifySSHPublicKey authorizes one new Gateway SSH transport using a Gitea user SSH key. +func VerifySSHPublicKey(ctx context.Context, manager *codespace_model.Manager, opts VerifySSHPublicKeyOptions) (*codespacev1.VerifySSHPublicKeyResponse, error) { + if manager == nil || manager.ID <= 0 { + return nil, errors.New("manager is required") + } + if !setting.Codespace.Enabled { + return denySSHAuth(SSHAuthDeniedStateUnavailable), nil + } + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return nil, err + } + key, err := normalizeUserSSHPublicKey(opts.PublicKey) + if err != nil { + return denySSHAuth(SSHAuthDeniedInvalidCredentials), nil + } + + var result *codespacev1.VerifySSHPublicKeyResponse + err = globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + currentManager, err := loadCodespaceManager(ctx, manager.ID) + if err != nil { + return err + } + if currentManager.RuntimeState != codespace_model.ManagerRuntimeStateOnline || isManagerOffline(currentManager) { + result = denySSHAuth(SSHAuthDeniedStateUnavailable) + return nil + } + + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceUUID).Get(codespace) + if err != nil { + return err + } + if !has { + result = denySSHAuth(SSHAuthDeniedCodespaceNotFound) + return nil + } + if codespace.ManagerID != manager.ID { + result = denySSHAuth(SSHAuthDeniedManagerMismatch) + return nil + } + if codespace.Status != codespace_model.StatusRunning { + result = denySSHAuth(SSHAuthDeniedCodespaceNotRunning) + return nil + } + if hasActiveOperation(codespace) && !isQueuedIdleStop(codespace) { + result = denySSHAuth(SSHAuthDeniedStateUnavailable) + return nil + } + + entry, hasEntry, err := getRuntimeMetadataEntry(opts.CodespaceUUID) + if err != nil { + return err + } + if !hasEntry || !runtimeMetadataReadyForRunning(codespace, entry.Metadata) { + result = denySSHAuth(SSHAuthDeniedMetadataRebuilding) + return nil + } + + user, err := user_model.GetUserByID(ctx, codespace.UserID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + result = denySSHAuth(SSHAuthDeniedLoginRestricted) + return nil + } + return err + } + canUseGateway, err := codespaceUserCanLogIn(ctx, user) + if err != nil { + return err + } + if !canUseGateway { + result = denySSHAuth(SSHAuthDeniedLoginRestricted) + return nil + } + verified, err := userOwnsSSHKey(ctx, codespace.UserID, key) + if err != nil { + return err + } + if !verified { + result = denySSHAuth(SSHAuthDeniedInvalidCredentials) + return nil + } + + now := time.Now().Unix() + nextGeneration, err := advanceCodespaceInteraction(ctx, codespace, now) + if err != nil { + if err == errInteractionVersionExhausted { + result = denySSHAuth(SSHAuthDeniedVersionExhausted) + return nil + } + return err + } + result = &codespacev1.VerifySSHPublicKeyResponse{ + Outcome: &codespacev1.VerifySSHPublicKeyResponse_Allowed{ + Allowed: &codespacev1.SSHAuthBinding{ + UserId: codespace.UserID, + InteractionGeneration: nextGeneration, + }, + }, + } + return nil + }) + }) + if err != nil { + return nil, err + } + return result, nil +} + +func normalizeUserSSHPublicKey(raw []byte) (normalizedUserSSHKey, error) { + publicKey, err := ssh.ParsePublicKey(raw) + if err != nil { + return normalizedUserSSHKey{}, err + } + return normalizedUserSSHKey{ + Wire: publicKey.Marshal(), + Fingerprint: ssh.FingerprintSHA256(publicKey), + }, nil +} + +func codespaceUserCanLogIn(ctx context.Context, user *user_model.User) (bool, error) { + if user == nil || !user.IsActive || user.ProhibitLogin || user.MustChangePassword { + return false, nil + } + if !setting.TwoFactorAuthEnforced { + return true, nil + } + has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, user.ID) + if err != nil { + return false, err + } + return has, nil +} + +func userOwnsSSHKey(ctx context.Context, ownerID int64, submitted normalizedUserSSHKey) (bool, error) { + keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + OwnerID: ownerID, + Fingerprint: submitted.Fingerprint, + KeyTypes: []asymkey_model.KeyType{asymkey_model.KeyTypeUser}, + }) + if err != nil { + return false, err + } + for _, key := range keys { + dbKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content)) + if err != nil { + return false, err + } + if bytes.Equal(dbKey.Marshal(), submitted.Wire) { + return true, nil + } + } + return false, nil +} + +func denySSHAuth(category string) *codespacev1.VerifySSHPublicKeyResponse { + return &codespacev1.VerifySSHPublicKeyResponse{ + Outcome: &codespacev1.VerifySSHPublicKeyResponse_Denied{ + Denied: &codespacev1.FailureDetail{Category: category}, + }, + } +} diff --git a/services/codespace/ssh_auth_test.go b/services/codespace/ssh_auth_test.go new file mode 100644 index 0000000000000..a5fe29242b54b --- /dev/null +++ b/services/codespace/ssh_auth_test.go @@ -0,0 +1,228 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + "time" + + asymkey_model "gitea.dev/models/asymkey" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/perm" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +func TestVerifySSHPublicKeyAllowsAndCancelsQueuedIdleStop(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "71717171-7171-4717-8717-717171717171" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 51, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerIdle, + OperationCreatedUnix: time.Now().Unix(), + InteractionGeneration: 9, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 51, []map[string]any{}))) + insertServiceUserSSHKey(t, 1, testGitSSHPublicKey) + + result, err := VerifySSHPublicKey(t.Context(), manager, VerifySSHPublicKeyOptions{ + CodespaceUUID: codespaceUUID, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + require.NotNil(t, result.GetAllowed()) + assert.EqualValues(t, 1, result.GetAllowed().GetUserId()) + assert.EqualValues(t, 10, result.GetAllowed().GetInteractionGeneration()) + + row := loadServiceCodespace(t, codespaceUUID) + assert.EqualValues(t, 10, row.InteractionGeneration) + assert.Empty(t, row.OperationType) + assert.Empty(t, row.OperationStatus) + assert.Empty(t, row.OperationTrigger) + assert.Positive(t, row.LastActiveUnix) +} + +func TestVerifySSHPublicKeyKeepsLifecycleUpdatedUnixWithoutIdleStop(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "71717171-7171-4717-8717-717171717172" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 51, + InteractionGeneration: 9, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 51, []map[string]any{}))) + insertServiceUserSSHKey(t, 1, testGitSSHPublicKey) + updatedUnix := loadServiceCodespace(t, codespaceUUID).UpdatedUnix + + result, err := VerifySSHPublicKey(t.Context(), manager, VerifySSHPublicKeyOptions{ + CodespaceUUID: codespaceUUID, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + require.NotNil(t, result.GetAllowed()) + assert.EqualValues(t, 10, result.GetAllowed().GetInteractionGeneration()) + + row := loadServiceCodespace(t, codespaceUUID) + assert.EqualValues(t, 10, row.InteractionGeneration) + assert.Equal(t, updatedUnix, row.UpdatedUnix) + assert.Positive(t, row.LastActiveUnix) +} + +func TestVerifySSHPublicKeyDeniesInvalidCredentials(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "72727272-7272-4727-8727-727272727272" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 52, + InteractionGeneration: 3, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 52, []map[string]any{}))) + insertServiceUserSSHKey(t, 1, testGitSSHPublicKey) + + result, err := VerifySSHPublicKey(t.Context(), manager, VerifySSHPublicKeyOptions{ + CodespaceUUID: codespaceUUID, + PublicKey: servicePublicKeyWire(t, testOtherGitSSHPublicKey), + }) + require.NoError(t, err) + assert.Equal(t, SSHAuthDeniedInvalidCredentials, result.GetDenied().GetCategory()) + assert.EqualValues(t, 3, loadServiceCodespace(t, codespaceUUID).InteractionGeneration) + + result, err = VerifySSHPublicKey(t.Context(), manager, VerifySSHPublicKeyOptions{ + CodespaceUUID: codespaceUUID, + PublicKey: []byte("not-ssh-wire"), + }) + require.NoError(t, err) + assert.Equal(t, SSHAuthDeniedInvalidCredentials, result.GetDenied().GetCategory()) +} + +func TestVerifySSHPublicKeyDeniesStateAndMetadata(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + insertServiceUserSSHKey(t, 1, testGitSSHPublicKey) + + activeUUID := "73737373-7373-4737-8737-737373737373" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: activeUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 53, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: time.Now().Unix(), + }) + require.NoError(t, putRuntimeMetadataEntry(activeUUID, serviceRuntimeMetadataEntry(t, 53, []map[string]any{}))) + result, err := VerifySSHPublicKey(t.Context(), manager, VerifySSHPublicKeyOptions{ + CodespaceUUID: activeUUID, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + assert.Equal(t, SSHAuthDeniedStateUnavailable, result.GetDenied().GetCategory()) + + missingMetadataUUID := "74747474-7474-4747-8747-747474747474" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: missingMetadataUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 54, + }) + result, err = VerifySSHPublicKey(t.Context(), manager, VerifySSHPublicKeyOptions{ + CodespaceUUID: missingMetadataUUID, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + assert.Equal(t, SSHAuthDeniedMetadataRebuilding, result.GetDenied().GetCategory()) +} + +func TestVerifySSHPublicKeyUsesUnifiedLoginRestrictions(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + t.Cleanup(test.MockVariableValue(&setting.TwoFactorAuthEnforced, true)) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + insertServiceUserSSHKey(t, 1, testGitSSHPublicKey) + restrictedUUID := "75757575-7575-4757-8757-757575757575" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: restrictedUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 55, + InteractionGeneration: 4, + }) + require.NoError(t, putRuntimeMetadataEntry(restrictedUUID, serviceRuntimeMetadataEntry(t, 55, []map[string]any{}))) + _, err := db.GetEngine(t.Context()). + ID(1). + Cols("must_change_password"). + Update(&user_model.User{MustChangePassword: true}) + require.NoError(t, err) + + result, err := VerifySSHPublicKey(t.Context(), manager, VerifySSHPublicKeyOptions{ + CodespaceUUID: restrictedUUID, + PublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + assert.Equal(t, SSHAuthDeniedLoginRestricted, result.GetDenied().GetCategory()) + assert.EqualValues(t, 4, loadServiceCodespace(t, restrictedUUID).InteractionGeneration) + + insertServiceUserSSHKey(t, 32, testOtherGitSSHPublicKey) + webauthnUUID := "76767676-7676-4767-8767-767676767676" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: webauthnUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 56, + InteractionGeneration: 7, + }) + _, err = db.GetEngine(t.Context()). + ID(webauthnUUID). + Cols("user_id"). + Update(&codespace_model.Codespace{UserID: 32}) + require.NoError(t, err) + require.NoError(t, putRuntimeMetadataEntry(webauthnUUID, serviceRuntimeMetadataEntry(t, 56, []map[string]any{}))) + + result, err = VerifySSHPublicKey(t.Context(), manager, VerifySSHPublicKeyOptions{ + CodespaceUUID: webauthnUUID, + PublicKey: servicePublicKeyWire(t, testOtherGitSSHPublicKey), + }) + require.NoError(t, err) + require.NotNil(t, result.GetAllowed()) + assert.EqualValues(t, 32, result.GetAllowed().GetUserId()) + assert.EqualValues(t, 8, result.GetAllowed().GetInteractionGeneration()) +} + +func insertServiceUserSSHKey(t *testing.T, ownerID int64, content string) { + t.Helper() + canonical := serviceCanonicalPublicKey(t, content) + publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(canonical)) + require.NoError(t, err) + require.NoError(t, db.Insert(t.Context(), &asymkey_model.PublicKey{ + OwnerID: ownerID, + Name: "test-user-key", + Fingerprint: ssh.FingerprintSHA256(publicKey), + Content: canonical, + Mode: perm.AccessModeWrite, + Type: asymkey_model.KeyTypeUser, + Verified: true, + })) +} diff --git a/services/codespace/token.go b/services/codespace/token.go new file mode 100644 index 0000000000000..6da2ecdf8e671 --- /dev/null +++ b/services/codespace/token.go @@ -0,0 +1,473 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + auth_model "gitea.dev/models/auth" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + perm_model "gitea.dev/models/perm" + unit_model "gitea.dev/models/unit" + user_model "gitea.dev/models/user" + "gitea.dev/modules/globallock" + secret_module "gitea.dev/modules/secret" + "gitea.dev/modules/setting" + "gitea.dev/modules/util" +) + +const codespaceTokenPrefix = "gcs_" + +// GiteaTokenScope is the fixed PAT category scope represented by a Codespace Token. +const GiteaTokenScope = "write:issue,write:repository,read:user" + +var ( + // ErrRequestRuntimeAccessNotFound is returned when the Codespace no longer exists. + ErrRequestRuntimeAccessNotFound = errors.New("codespace not found") + // ErrRequestRuntimeAccessManagerMismatch is returned when the Codespace is bound to another Manager. + ErrRequestRuntimeAccessManagerMismatch = errors.New("codespace belongs to another manager") + // ErrRequestRuntimeAccessStateUnavailable is returned when the lifecycle state cannot receive runtime access material. + ErrRequestRuntimeAccessStateUnavailable = errors.New("codespace state does not allow requesting runtime access") + // ErrRequestRuntimeAccessManagerOffline is returned when the authenticated Manager is not usable. + ErrRequestRuntimeAccessManagerOffline = errors.New("manager is not online") + // ErrRequestRuntimeAccessUserNotFound is returned when the Codespace creator row is missing. + ErrRequestRuntimeAccessUserNotFound = errors.New("codespace user not found") + // ErrResolveGiteaTokenUnmatched is returned when the plaintext token is not a Codespace Token. + ErrResolveGiteaTokenUnmatched = errors.New("codespace gitea token unmatched") + // ErrResolveGiteaTokenRejected is returned when the Codespace Token is malformed or not current. + ErrResolveGiteaTokenRejected = errors.New("codespace gitea token rejected") + // ErrResolveGiteaTokenForbidden is returned when a current Codespace Token cannot be used now. + ErrResolveGiteaTokenForbidden = errors.New("codespace gitea token forbidden") +) + +// RequestRuntimeAccessOptions identifies the Codespace operation preparing runtime access. +type RequestRuntimeAccessOptions struct { + CodespaceUUID string + OperationRVersion int64 + GitSSHPublicKey []byte +} + +// RequestRuntimeAccessResult contains the short-lived runtime inputs returned to a Manager. +type RequestRuntimeAccessResult struct { + Token string + ServerURL string + Secrets []RuntimeSecret + GitSSHKnownHosts []string +} + +type requestRuntimeCredentialsOptions struct { + CodespaceUUID string + OperationRVersion int64 +} + +type requestRuntimeCredentialsResult struct { + Token string + ServerURL string + Secrets []RuntimeSecret +} + +// GiteaTokenAuthSnapshot contains the current Codespace Token authentication result for one request. +type GiteaTokenAuthSnapshot struct { + User *user_model.User + CodespaceUUID string + RepoID int64 + Scope auth_model.AccessTokenScope + RepositoryPermissions map[int64]map[unit_model.Type]perm_model.AccessMode +} + +// CodespaceTokenAllowsRepository reports whether the Codespace authorization snapshot includes a repository unit permission. +func (s *GiteaTokenAuthSnapshot) CodespaceTokenAllowsRepository(repoID int64, unitType unit_model.Type, mode perm_model.AccessMode) bool { + if s == nil || repoID <= 0 || mode < perm_model.AccessModeRead || mode > perm_model.AccessModeWrite { + return false + } + if repoID == s.RepoID { + return true + } + return s.RepositoryPermissions[repoID][unitType] >= mode +} + +// CodespaceTokenAllowsAnyRepository reports whether the snapshot has any authenticated access to a repository. +func (s *GiteaTokenAuthSnapshot) CodespaceTokenAllowsAnyRepository(repoID int64) bool { + if s == nil || repoID <= 0 { + return false + } + return repoID == s.RepoID || len(s.RepositoryPermissions[repoID]) > 0 +} + +type giteaTokenAuthCandidate struct { + Token *codespace_model.GiteaToken `xorm:"extends"` + Codespace *codespace_model.Codespace `xorm:"extends"` + User *user_model.User `xorm:"extends"` + TwoFactor *auth_model.TwoFactor `xorm:"extends"` + WebAuthn *auth_model.WebAuthnCredential `xorm:"extends"` +} + +func (c *giteaTokenAuthCandidate) hasTwoFactorOrWebAuthn() bool { + return c != nil && + (c.TwoFactor != nil && c.TwoFactor.ID > 0 || + c.WebAuthn != nil && c.WebAuthn.ID > 0) +} + +// CodespaceTokenRepoID returns the repository bound to this Codespace Token snapshot. +func (s *GiteaTokenAuthSnapshot) CodespaceTokenRepoID() int64 { + if s == nil { + return 0 + } + return s.RepoID +} + +// RequestRuntimeAccess returns the current token, user secrets, and Git SSH trust material. +func RequestRuntimeAccess(ctx context.Context, manager *codespace_model.Manager, opts RequestRuntimeAccessOptions) (*RequestRuntimeAccessResult, error) { + if opts.OperationRVersion <= 0 { + return nil, errors.New("operation_rversion must be positive") + } + credentials, err := requestRuntimeCredentials(ctx, manager, requestRuntimeCredentialsOptions{ + CodespaceUUID: opts.CodespaceUUID, + OperationRVersion: opts.OperationRVersion, + }) + if err != nil { + return nil, err + } + knownHosts, err := ensureRuntimeGitSSHKey(ctx, manager, runtimeGitSSHKeyOptions{ + CodespaceUUID: opts.CodespaceUUID, + OperationRVersion: opts.OperationRVersion, + PublicKey: opts.GitSSHPublicKey, + }) + if err != nil { + return nil, err + } + return &RequestRuntimeAccessResult{ + Token: credentials.Token, + ServerURL: credentials.ServerURL, + Secrets: credentials.Secrets, + GitSSHKnownHosts: knownHosts, + }, nil +} + +func requestRuntimeCredentials(ctx context.Context, manager *codespace_model.Manager, opts requestRuntimeCredentialsOptions) (*requestRuntimeCredentialsResult, error) { + if !setting.Codespace.Enabled { + return nil, ErrRequestRuntimeAccessStateUnavailable + } + if manager == nil || manager.ID <= 0 { + return nil, errors.New("manager is required") + } + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return nil, err + } + if opts.OperationRVersion <= 0 { + return nil, errors.New("operation_rversion must be positive") + } + + var ( + token string + secrets []RuntimeSecret + ) + err := globallock.LockAndDo(ctx, requestRuntimeCredentialsLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + allowed, err := currentManagerAllowsOnlineOrRecovering(ctx, manager.ID) + if err != nil { + return err + } + if !allowed { + return ErrRequestRuntimeAccessManagerOffline + } + codespace, err := loadRuntimeAccessCodespace(ctx, manager.ID, opts.CodespaceUUID, opts.OperationRVersion) + if err != nil { + return err + } + user, err := user_model.GetUserByID(ctx, codespace.UserID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + return ErrRequestRuntimeAccessUserNotFound + } + return err + } + + secrets, err = resolveCodespaceRuntimeSecrets(ctx, user, codespace) + if err != nil { + return err + } + + existingToken, ok, err := readCurrentGiteaToken(ctx, opts.CodespaceUUID) + if err != nil { + return err + } + if ok { + token = existingToken + return nil + } + generatedToken, err := insertNewGiteaToken(ctx, opts.CodespaceUUID) + if err != nil { + return err + } + token = generatedToken + return nil + }) + }) + if err != nil { + return nil, err + } + return &requestRuntimeCredentialsResult{ + Token: token, + ServerURL: setting.AppURL, + Secrets: secrets, + }, nil +} + +func runtimeAccessLifecycleAllows(codespace *codespace_model.Codespace, operationRVersion, now int64) bool { + if codespace.OperationRVersion != operationRVersion { + return false + } + if codespace.Status == codespace_model.StatusRunning { + return !hasActiveOperation(codespace) + } + return createOrResumeOperationActive(codespace, now) +} + +func loadRuntimeAccessCodespace(ctx context.Context, managerID int64, codespaceUUID string, operationRVersion int64) (*codespace_model.Codespace, error) { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(codespaceUUID).Get(codespace) + if err != nil { + return nil, err + } + if !has { + return nil, ErrRequestRuntimeAccessNotFound + } + if codespace.ManagerID != managerID { + return nil, ErrRequestRuntimeAccessManagerMismatch + } + if !runtimeAccessLifecycleAllows(codespace, operationRVersion, time.Now().Unix()) { + return nil, ErrRequestRuntimeAccessStateUnavailable + } + return codespace, nil +} + +// ResolveGiteaToken authenticates a plaintext Codespace Token and returns the request snapshot. +func ResolveGiteaToken(ctx context.Context, token string) (*GiteaTokenAuthSnapshot, error) { + if !strings.HasPrefix(token, codespaceTokenPrefix) { + return nil, ErrResolveGiteaTokenUnmatched + } + if !validCodespaceTokenPlaintext(token) { + return nil, ErrResolveGiteaTokenRejected + } + if !setting.Codespace.Enabled { + return nil, fmt.Errorf("%w: state_unavailable", ErrResolveGiteaTokenForbidden) + } + + candidates, err := findGiteaTokenAuthCandidates(ctx, token[len(token)-8:]) + if err != nil { + return nil, err + } + for _, candidate := range candidates { + if candidate.Token == nil || !verifyCodespaceGiteaToken(candidate.Token, token) { + continue + } + codespace := candidate.Codespace + if codespace == nil || codespace.UUID == "" { + return nil, ErrResolveGiteaTokenRejected + } + if !giteaTokenLifecycleAllows(codespace, time.Now().Unix()) { + return nil, fmt.Errorf("%w: codespace_not_running", ErrResolveGiteaTokenForbidden) + } + user := candidate.User + if user == nil || user.ID == 0 { + return nil, ErrResolveGiteaTokenRejected + } + if err := checkCodespaceTokenUserAllowed(user, candidate.hasTwoFactorOrWebAuthn()); err != nil { + return nil, err + } + scope, err := auth_model.AccessTokenScope(GiteaTokenScope).Normalize() + if err != nil { + return nil, err + } + permissions, err := loadTokenRepositoryPermissions(ctx, codespace) + if err != nil { + return nil, err + } + return &GiteaTokenAuthSnapshot{ + User: user, + CodespaceUUID: codespace.UUID, + RepoID: codespace.RepoID, + Scope: scope, + RepositoryPermissions: permissions, + }, nil + } + return nil, ErrResolveGiteaTokenRejected +} + +func loadTokenRepositoryPermissions(ctx context.Context, codespace *codespace_model.Codespace) (map[int64]map[unit_model.Type]perm_model.AccessMode, error) { + permissions := make(map[int64]map[unit_model.Type]perm_model.AccessMode) + if codespace.PermissionAuthorizationID == 0 { + return permissions, nil + } + authorization := new(codespace_model.PermissionAuthorization) + has, err := db.GetEngine(ctx).ID(codespace.PermissionAuthorizationID).Get(authorization) + if err != nil { + return nil, err + } + if !has || authorization.UserID != codespace.UserID || authorization.SourceRepoID != codespace.RepoID || authorization.RevokedUnix != 0 { + return permissions, nil + } + var rules []*codespace_model.PermissionRepository + if err := db.GetEngine(ctx).Where("authorization_id = ?", authorization.ID).Find(&rules); err != nil { + return nil, err + } + for _, rule := range rules { + if rule.GrantedMode < perm_model.AccessModeRead || rule.GrantedMode > perm_model.AccessModeWrite { + continue + } + if permissions[rule.TargetRepoID] == nil { + permissions[rule.TargetRepoID] = make(map[unit_model.Type]perm_model.AccessMode) + } + permissions[rule.TargetRepoID][rule.UnitType] = rule.GrantedMode + } + return permissions, nil +} + +func findGiteaTokenAuthCandidates(ctx context.Context, tokenLastEight string) ([]*giteaTokenAuthCandidate, error) { + rows, err := db.GetEngine(ctx). + Table("codespace_gitea_token"). + Where("codespace_gitea_token.token_last_eight = ?", tokenLastEight). + Join("INNER", "codespace", "codespace.uuid = codespace_gitea_token.codespace_uuid"). + Join("INNER", "`user`", "`user`.id = codespace.user_id"). + Join("LEFT", "two_factor", "two_factor.uid = `user`.id"). + Join("LEFT", "webauthn_credential", "webauthn_credential.user_id = `user`.id"). + Limit(20). + Rows(new(giteaTokenAuthCandidate)) + if err != nil { + return nil, err + } + defer rows.Close() + + candidates := make([]*giteaTokenAuthCandidate, 0, 1) + for rows.Next() { + candidate := new(giteaTokenAuthCandidate) + if err := rows.Scan(candidate); err != nil { + return nil, err + } + candidates = append(candidates, candidate) + } + return candidates, rows.Err() +} + +func giteaTokenLifecycleAllows(codespace *codespace_model.Codespace, now int64) bool { + if codespace.Status == codespace_model.StatusRunning { + return true + } + return createOrResumeOperationActive(codespace, now) +} + +func checkCodespaceTokenUserAllowed(user *user_model.User, hasTwoFactorOrWebAuthn bool) error { + if user == nil || !user.IsActive || user.ProhibitLogin || user.MustChangePassword { + return fmt.Errorf("%w: login_restricted", ErrResolveGiteaTokenForbidden) + } + if setting.TwoFactorAuthEnforced && !hasTwoFactorOrWebAuthn { + return fmt.Errorf("%w: login_restricted", ErrResolveGiteaTokenForbidden) + } + return nil +} + +func readCurrentGiteaToken(ctx context.Context, codespaceUUID string) (string, bool, error) { + row := new(codespace_model.GiteaToken) + has, err := db.GetEngine(ctx).ID(codespaceUUID).Get(row) + if err != nil || !has { + return "", false, err + } + token, err := secret_module.DecryptSecret(setting.SecretKey, row.TokenEncrypted) + if err != nil || !validCodespaceTokenPlaintext(token) || !verifyCodespaceGiteaToken(row, token) { + if _, deleteErr := db.GetEngine(ctx).ID(codespaceUUID).Delete(new(codespace_model.GiteaToken)); deleteErr != nil { + return "", false, deleteErr + } + return "", false, nil + } + return token, true, nil +} + +func hasValidCurrentGiteaToken(ctx context.Context, codespaceUUID string) (bool, error) { + row := new(codespace_model.GiteaToken) + has, err := db.GetEngine(ctx).ID(codespaceUUID).Get(row) + if err != nil || !has { + return false, err + } + token, err := secret_module.DecryptSecret(setting.SecretKey, row.TokenEncrypted) + if err != nil { + return false, nil + } + return validCodespaceTokenPlaintext(token) && verifyCodespaceGiteaToken(row, token), nil +} + +func insertNewGiteaToken(ctx context.Context, codespaceUUID string) (string, error) { + token := generateCodespaceGiteaToken() + salt := util.CryptoRandomString(10) + encrypted, err := secret_module.EncryptSecret(setting.SecretKey, token) + if err != nil { + return "", err + } + row := &codespace_model.GiteaToken{ + CodespaceUUID: codespaceUUID, + TokenHash: auth_model.HashToken(token, salt), + TokenSalt: salt, + TokenLastEight: token[len(token)-8:], + TokenEncrypted: encrypted, + } + if _, err := db.GetEngine(ctx).Insert(row); err != nil { + existing, ok, readErr := readCurrentGiteaToken(ctx, codespaceUUID) + if readErr != nil { + return "", readErr + } + if ok { + return existing, nil + } + return "", err + } + return token, nil +} + +func verifyCodespaceGiteaToken(row *codespace_model.GiteaToken, token string) bool { + if row == nil || row.TokenHash == "" || row.TokenSalt == "" { + return false + } + if !validCodespaceTokenPlaintext(token) || row.TokenLastEight != token[len(token)-8:] { + return false + } + hash := auth_model.HashToken(token, row.TokenSalt) + return subtle.ConstantTimeCompare([]byte(row.TokenHash), []byte(hash)) == 1 +} + +func generateCodespaceGiteaToken() string { + return codespaceTokenPrefix + hex.EncodeToString(util.CryptoRandomBytes(32)) +} + +func validCodespaceTokenPlaintext(token string) bool { + return IsGiteaTokenPlaintext(token) +} + +// IsGiteaTokenPlaintext reports whether token has the Codespace Token plaintext format. +func IsGiteaTokenPlaintext(token string) bool { + if !IsGiteaTokenCandidate(token) { + return false + } + raw := strings.TrimPrefix(token, codespaceTokenPrefix) + if len(raw) != 64 { + return false + } + _, err := hex.DecodeString(raw) + return err == nil +} + +// IsGiteaTokenCandidate reports whether token uses the Codespace Token prefix. +func IsGiteaTokenCandidate(token string) bool { + return strings.HasPrefix(token, codespaceTokenPrefix) +} + +func requestRuntimeCredentialsLockKey(codespaceUUID string) string { + return "codespace_gitea_token_" + codespaceUUID +} diff --git a/services/codespace/token_test.go b/services/codespace/token_test.go new file mode 100644 index 0000000000000..fd1f3bafcc18f --- /dev/null +++ b/services/codespace/token_test.go @@ -0,0 +1,359 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "strings" + "testing" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/perm" + "gitea.dev/models/unit" + "gitea.dev/models/unittest" + secret_module "gitea.dev/modules/secret" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRuntimeCredentialsCreateReturnsStableToken(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 7, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + encryptedSecret, err := secret_module.EncryptSecret(setting.SecretKey, "runtime-value") + require.NoError(t, err) + userSecret := &codespace_model.UserSecret{UserID: 1, Name: "RUNTIME_SECRET", DataEncrypted: encryptedSecret, DataSize: 13} + require.NoError(t, db.Insert(t.Context(), userSecret)) + require.NoError(t, db.Insert(t.Context(), &codespace_model.UserSecretRepository{SecretID: userSecret.ID, RepoID: 2})) + + first, err := requestRuntimeCredentials(t.Context(), manager, requestRuntimeCredentialsOptions{CodespaceUUID: codespaceUUID, OperationRVersion: 7}) + require.NoError(t, err) + require.NotNil(t, first) + assert.True(t, strings.HasPrefix(first.Token, codespaceTokenPrefix)) + assert.Len(t, first.Token, len(codespaceTokenPrefix)+64) + assert.NotEmpty(t, first.ServerURL) + assert.Equal(t, []RuntimeSecret{{Name: "RUNTIME_SECRET", Value: "runtime-value"}}, first.Secrets) + row := loadServiceGiteaToken(t, codespaceUUID) + assert.Equal(t, first.Token[len(first.Token)-8:], row.TokenLastEight) + assert.True(t, verifyCodespaceGiteaToken(row, first.Token)) + + second, err := requestRuntimeCredentials(t.Context(), manager, requestRuntimeCredentialsOptions{CodespaceUUID: codespaceUUID, OperationRVersion: 7}) + require.NoError(t, err) + assert.Equal(t, first.Token, second.Token) +} + +func TestRuntimeCredentialsOmitsUnauthorizedSecrets(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + tests := []struct { + name string + codespaceUUID string + userID int64 + repoID int64 + refType string + refName string + allRepositories bool + }{ + { + name: "code write access revoked", + codespaceUUID: "0a0a0a0a-0a0a-4a0a-8a0a-0a0a0a0a0a0a", + userID: 5, + repoID: 2, + refType: "branch", + refName: "master", + }, + { + name: "external pull request", + codespaceUUID: "0b0b0b0b-0b0b-4b0b-8b0b-0b0b0b0b0b0b", + userID: 12, + repoID: 10, + refType: "pull", + refName: "refs/pull/1/head", + allRepositories: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: tt.codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 1, + }) + _, err := db.GetEngine(t.Context()).ID(tt.codespaceUUID).Cols("user_id", "repo_id", "ref_type", "ref_name").Update(&codespace_model.Codespace{ + UserID: tt.userID, RepoID: tt.repoID, RefType: tt.refType, RefName: tt.refName, + }) + require.NoError(t, err) + + encrypted, err := secret_module.EncryptSecret(setting.SecretKey, "must-not-be-returned") + require.NoError(t, err) + secret := &codespace_model.UserSecret{ + UserID: tt.userID, Name: "PRIVATE_VALUE", DataEncrypted: encrypted, DataSize: 20, AllRepositories: tt.allRepositories, + } + require.NoError(t, db.Insert(t.Context(), secret)) + if !tt.allRepositories { + require.NoError(t, db.Insert(t.Context(), &codespace_model.UserSecretRepository{SecretID: secret.ID, RepoID: tt.repoID})) + } + + result, err := requestRuntimeCredentials(t.Context(), manager, requestRuntimeCredentialsOptions{CodespaceUUID: tt.codespaceUUID, OperationRVersion: 1}) + require.NoError(t, err) + assert.NotEmpty(t, result.Token) + assert.Empty(t, result.Secrets) + }) + } +} + +func TestRuntimeCredentialsRepairsDamagedRow(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "ffffffff-ffff-4fff-8fff-ffffffffffff" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 8, + }) + badPlaintext := codespaceTokenPrefix + strings.Repeat("0", 64) + encrypted, err := secret_module.EncryptSecret(setting.SecretKey, badPlaintext) + require.NoError(t, err) + require.NoError(t, db.Insert(t.Context(), &codespace_model.GiteaToken{ + CodespaceUUID: codespaceUUID, + TokenHash: "wrong-hash", + TokenSalt: "salt", + TokenLastEight: badPlaintext[len(badPlaintext)-8:], + TokenEncrypted: encrypted, + })) + + result, err := requestRuntimeCredentials(t.Context(), manager, requestRuntimeCredentialsOptions{CodespaceUUID: codespaceUUID, OperationRVersion: 8}) + require.NoError(t, err) + assert.NotEqual(t, badPlaintext, result.Token) + row := loadServiceGiteaToken(t, codespaceUUID) + assert.True(t, verifyCodespaceGiteaToken(row, result.Token)) + + count, err := db.GetEngine(t.Context()).Where("codespace_uuid = ?", codespaceUUID).Count(new(codespace_model.GiteaToken)) + require.NoError(t, err) + assert.EqualValues(t, 1, count) +} + +func TestRuntimeCredentialsRejectsUnavailableState(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "10101010-1010-4010-8010-101010101010" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 9, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusRunning, + OperationTrigger: codespace_model.OperationTriggerUser, + OperationCreatedUnix: 10, + OperationStartedUnix: 11, + OperationDeadlineUnix: time.Now().Add(time.Hour).Unix(), + }) + + _, err := requestRuntimeCredentials(t.Context(), manager, requestRuntimeCredentialsOptions{CodespaceUUID: codespaceUUID, OperationRVersion: 9}) + require.ErrorIs(t, err, ErrRequestRuntimeAccessStateUnavailable) + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", codespaceUUID) +} + +func TestRuntimeCredentialsRejectsDisabledCodespace(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + t.Cleanup(test.MockVariableValue(&setting.Codespace.Enabled, false)) + + manager := insertServiceManager(t) + codespaceUUID := "20202020-2020-4020-8020-202020202020" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 10, + }) + + _, err := requestRuntimeCredentials(t.Context(), manager, requestRuntimeCredentialsOptions{CodespaceUUID: codespaceUUID, OperationRVersion: 10}) + require.ErrorIs(t, err, ErrRequestRuntimeAccessStateUnavailable) + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", codespaceUUID) +} + +func TestRuntimeCredentialsUsesCurrentManagerAvailability(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + staleManager := *manager + codespaceUUID := "30303030-3030-4030-8030-303030303031" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 10, + }) + _, err := db.GetEngine(t.Context()). + ID(manager.ID). + Cols("last_online_unix"). + Update(&codespace_model.Manager{LastOnlineUnix: 1}) + require.NoError(t, err) + + _, err = requestRuntimeCredentials(t.Context(), &staleManager, requestRuntimeCredentialsOptions{CodespaceUUID: codespaceUUID, OperationRVersion: 10}) + require.ErrorIs(t, err, ErrRequestRuntimeAccessManagerOffline) + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", codespaceUUID) + + _, err = db.GetEngine(t.Context()). + ID(manager.ID). + Cols("runtime_state", "last_online_unix"). + Update(&codespace_model.Manager{ + RuntimeState: codespace_model.ManagerRuntimeStateRecovering, + LastOnlineUnix: time.Now().Unix(), + }) + require.NoError(t, err) + result, err := requestRuntimeCredentials(t.Context(), &staleManager, requestRuntimeCredentialsOptions{CodespaceUUID: codespaceUUID, OperationRVersion: 10}) + require.NoError(t, err) + assert.NotEmpty(t, result.Token) +} + +func TestResolveGiteaTokenRequiresTwoFactorWhenEnforced(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + t.Cleanup(test.MockVariableValue(&setting.TwoFactorAuthEnforced, true)) + + token := createRunningServiceGiteaTokenForUser(t, "30303030-3030-4030-8030-303030303030", 1) + + snapshot, err := ResolveGiteaToken(t.Context(), token) + require.ErrorIs(t, err, ErrResolveGiteaTokenForbidden) + assert.Nil(t, snapshot) +} + +func TestResolveGiteaTokenAcceptsTwoFactorOrWebAuthnWhenEnforced(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + t.Cleanup(test.MockVariableValue(&setting.TwoFactorAuthEnforced, true)) + + tests := []struct { + name string + codespaceUUID string + userID int64 + }{ + { + name: "totp", + codespaceUUID: "40404040-4040-4040-8040-404040404040", + userID: 24, + }, + { + name: "webauthn", + codespaceUUID: "50505050-5050-4050-8050-505050505050", + userID: 32, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token := createRunningServiceGiteaTokenForUser(t, tt.codespaceUUID, tt.userID) + + snapshot, err := ResolveGiteaToken(t.Context(), token) + require.NoError(t, err) + require.NotNil(t, snapshot) + assert.Equal(t, tt.userID, snapshot.User.ID) + assert.Equal(t, tt.codespaceUUID, snapshot.CodespaceUUID) + assert.EqualValues(t, 2, snapshot.RepoID) + }) + } +} + +func TestLoadTokenRepositoryPermissions(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + now := time.Now().Unix() + authorization := &codespace_model.PermissionAuthorization{ + UserID: 1, SourceRepoID: 2, RequestHash: "token-permissions", + CreatedUnix: now, UpdatedUnix: now, + } + require.NoError(t, db.Insert(t.Context(), authorization)) + require.NoError(t, db.Insert(t.Context(), + &codespace_model.PermissionRepository{ + AuthorizationID: authorization.ID, TargetRepoID: 3, UnitType: unit.TypeCode, + RequestedMode: perm.AccessModeWrite, GrantedMode: perm.AccessModeRead, + }, + &codespace_model.PermissionRepository{ + AuthorizationID: authorization.ID, TargetRepoID: 3, UnitType: unit.TypeIssues, + RequestedMode: perm.AccessModeWrite, GrantedMode: perm.AccessModeWrite, + }, + )) + codespace := &codespace_model.Codespace{UserID: 1, RepoID: 2, PermissionAuthorizationID: authorization.ID} + + permissions, err := loadTokenRepositoryPermissions(t.Context(), codespace) + require.NoError(t, err) + snapshot := &GiteaTokenAuthSnapshot{RepoID: codespace.RepoID, RepositoryPermissions: permissions} + assert.True(t, snapshot.CodespaceTokenAllowsRepository(3, unit.TypeCode, perm.AccessModeRead)) + assert.False(t, snapshot.CodespaceTokenAllowsRepository(3, unit.TypeCode, perm.AccessModeWrite)) + assert.True(t, snapshot.CodespaceTokenAllowsRepository(3, unit.TypeIssues, perm.AccessModeWrite)) + assert.False(t, snapshot.CodespaceTokenAllowsRepository(3, unit.TypeWiki, perm.AccessModeRead)) + + for _, tt := range []struct { + name string + userID int64 + sourceRepoID int64 + revokedUnix int64 + }{ + {name: "revoked", userID: 1, sourceRepoID: 2, revokedUnix: now}, + {name: "different-user", userID: 2, sourceRepoID: 2}, + {name: "different-source", userID: 1, sourceRepoID: 1}, + } { + t.Run(tt.name, func(t *testing.T) { + authorization.RevokedUnix = tt.revokedUnix + _, err := db.GetEngine(t.Context()).ID(authorization.ID).Cols("revoked_unix").Update(authorization) + require.NoError(t, err) + codespace.UserID = tt.userID + codespace.RepoID = tt.sourceRepoID + + permissions, err := loadTokenRepositoryPermissions(t.Context(), codespace) + require.NoError(t, err) + assert.Empty(t, permissions) + }) + } +} + +func createRunningServiceGiteaTokenForUser(t *testing.T, codespaceUUID string, userID int64) string { + t.Helper() + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 11, + }) + _, err := db.GetEngine(t.Context()). + ID(codespaceUUID). + Cols("user_id"). + Update(&codespace_model.Codespace{UserID: userID}) + require.NoError(t, err) + + result, err := requestRuntimeCredentials(t.Context(), manager, requestRuntimeCredentialsOptions{CodespaceUUID: codespaceUUID, OperationRVersion: 11}) + require.NoError(t, err) + require.NotNil(t, result) + return result.Token +} + +func loadServiceGiteaToken(t *testing.T, codespaceUUID string) *codespace_model.GiteaToken { + t.Helper() + row := new(codespace_model.GiteaToken) + has, err := db.GetEngine(t.Context()).ID(codespaceUUID).Get(row) + require.NoError(t, err) + require.True(t, has) + return row +} diff --git a/services/codespace/user_delete.go b/services/codespace/user_delete.go new file mode 100644 index 0000000000000..06eefa1ee456a --- /dev/null +++ b/services/codespace/user_delete.go @@ -0,0 +1,150 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/modules/globallock" +) + +const userDeleteBatchSize = 100 + +// DeleteUserResources removes Codespace resources created or registered by one user. +func DeleteUserResources(ctx context.Context, userID int64) error { + if userID <= 0 { + return errors.New("user_id must be positive") + } + return globallock.LockAndDo(ctx, codespaceUserRelationLockKey(userID), func(ctx context.Context) error { + return deleteUserResourcesLocked(ctx, userID) + }) +} + +// WithUserResourcesDeleted holds the user relation lock after deleting Codespace resources. +func WithUserResourcesDeleted(ctx context.Context, userID int64, fn func(context.Context) error) error { + if userID <= 0 { + return errors.New("user_id must be positive") + } + return globallock.LockAndDo(ctx, codespaceUserRelationLockKey(userID), func(ctx context.Context) error { + if err := deleteUserResourcesLocked(ctx, userID); err != nil { + return err + } + return fn(ctx) + }) +} + +func deleteUserResourcesLocked(ctx context.Context, userID int64) error { + if err := deleteUserManagersLocked(ctx, userID); err != nil { + return err + } + if err := deleteUserCodespacesLocked(ctx, userID); err != nil { + return err + } + return db.WithTx(ctx, func(ctx context.Context) error { + var secrets []*codespace_model.UserSecret + if err := db.GetEngine(ctx).Where("user_id = ?", userID).Find(&secrets); err != nil { + return err + } + for _, secret := range secrets { + if _, err := db.GetEngine(ctx).Where("secret_id = ?", secret.ID).Delete(new(codespace_model.UserSecretRepository)); err != nil { + return err + } + } + if _, err := db.GetEngine(ctx).Where("user_id = ?", userID).Delete(new(codespace_model.UserSecret)); err != nil { + return err + } + var authorizations []*codespace_model.PermissionAuthorization + if err := db.GetEngine(ctx).Where("user_id = ?", userID).Find(&authorizations); err != nil { + return err + } + for _, authorization := range authorizations { + if _, err := db.GetEngine(ctx).Where("authorization_id = ?", authorization.ID).Delete(new(codespace_model.PermissionRepository)); err != nil { + return err + } + } + if _, err := db.GetEngine(ctx).Where("user_id = ?", userID).Delete(new(codespace_model.PermissionAuthorization)); err != nil { + return err + } + if _, err := db.GetEngine(ctx).Where("user_id = ?", userID).Delete(new(codespace_model.ManagerToken)); err != nil { + return err + } + hasManager, err := db.GetEngine(ctx).Where("user_id = ?", userID).Exist(new(codespace_model.Manager)) + if err != nil { + return err + } + if hasManager { + return fmt.Errorf("codespace managers still exist for user %d", userID) + } + hasCodespace, err := db.GetEngine(ctx).Where("user_id = ?", userID).Exist(new(codespace_model.Codespace)) + if err != nil { + return err + } + if hasCodespace { + return fmt.Errorf("codespaces still exist for user %d", userID) + } + return nil + }) +} + +func deleteUserManagersLocked(ctx context.Context, userID int64) error { + for { + var managers []*codespace_model.Manager + if err := db.GetEngine(ctx). + Where("user_id = ?", userID). + Asc("id"). + Limit(userDeleteBatchSize). + Find(&managers); err != nil { + return err + } + if len(managers) == 0 { + return nil + } + for _, manager := range managers { + err := deleteManagerIdentityLocked(ctx, manager.ID, userDeleteBatchSize, func(current *codespace_model.Manager) (bool, error) { + return current.UserID == userID, nil + }) + if err != nil { + return err + } + } + } +} + +func deleteUserCodespacesLocked(ctx context.Context, userID int64) error { + for { + var rows []*codespace_model.Codespace + if err := db.GetEngine(ctx). + Where("user_id = ?", userID). + Asc("uuid"). + Limit(userDeleteBatchSize). + Find(&rows); err != nil { + return err + } + if len(rows) == 0 { + return nil + } + for _, row := range rows { + if err := deleteUserCodespace(ctx, userID, row.UUID); err != nil { + return err + } + } + } +} + +func deleteUserCodespace(ctx context.Context, userID int64, codespaceUUID string) error { + return globallock.LockAndDo(ctx, codespaceStateLockKey(codespaceUUID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(codespaceUUID).Get(codespace) + if err != nil || !has || codespace.UserID != userID { + return err + } + return deleteCodespaceForFinal(ctx, codespaceUUID) + }) + }) +} diff --git a/services/codespace/user_delete_test.go b/services/codespace/user_delete_test.go new file mode 100644 index 0000000000000..1544c3829250b --- /dev/null +++ b/services/codespace/user_delete_test.go @@ -0,0 +1,79 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/perm" + "gitea.dev/models/unit" + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/require" +) + +func TestDeleteUserResourcesOnlyCleansPersonalResources(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + userID := int64(2) + userToken, err := GetOrCreateRegistrationToken(t.Context(), ManagerSettingsOptions{ + Scope: ManagerSettingsScopeUser, + UserID: userID, + }) + require.NoError(t, err) + globalToken, err := GetOrCreateRegistrationToken(t.Context(), ManagerSettingsOptions{Scope: ManagerSettingsScopeSite}) + require.NoError(t, err) + + userManager := insertServiceManager(t) + userManager.UserID = userID + _, err = db.GetEngine(t.Context()).ID(userManager.ID).Cols("user_id").Update(userManager) + require.NoError(t, err) + insertSettingsManagerAddress(t, userManager.ID, codespace_model.ManagerAddressGateway, "https://user-delete.example.com") + + ownedUUID := "67676767-6767-4767-8767-676767676767" + insertServiceCodespace(t, userManager.ID, &codespace_model.Codespace{ + UUID: ownedUUID, + UserID: userID, + RepoID: 2, + Status: codespace_model.StatusRunning, + OperationRVersion: 67, + }) + repositoryOwnedUUID := "68686868-6868-4868-8868-686868686868" + insertServiceCodespace(t, 0, &codespace_model.Codespace{ + UUID: repositoryOwnedUUID, + UserID: 1, + RepoID: 1, + Status: codespace_model.StatusStopped, + OperationRVersion: 68, + }) + authorization := &codespace_model.PermissionAuthorization{ + UserID: userID, SourceRepoID: 1, RequestHash: "user-delete", + CreatedUnix: 1, UpdatedUnix: 1, + } + require.NoError(t, db.Insert(t.Context(), authorization)) + rule := &codespace_model.PermissionRepository{ + AuthorizationID: authorization.ID, TargetRepoID: 2, UnitType: unit.TypeCode, + RequestedMode: perm.AccessModeRead, GrantedMode: perm.AccessModeRead, + } + require.NoError(t, db.Insert(t.Context(), rule)) + secret := &codespace_model.UserSecret{UserID: userID, Name: "DATABASE_PASSWORD", DataEncrypted: "encrypted", DataSize: 8} + require.NoError(t, db.Insert(t.Context(), secret)) + secretRepository := &codespace_model.UserSecretRepository{SecretID: secret.ID, RepoID: 2} + require.NoError(t, db.Insert(t.Context(), secretRepository)) + + require.NoError(t, DeleteUserResources(t.Context(), userID)) + + assertServiceNotExists(t, new(codespace_model.Manager), "id = ?", userManager.ID) + assertServiceNotExists(t, new(codespace_model.ManagerAddress), "manager_id = ?", userManager.ID) + assertServiceNotExists(t, new(codespace_model.Codespace), "uuid = ?", ownedUUID) + assertServiceExists(t, new(codespace_model.Codespace), "uuid = ?", repositoryOwnedUUID) + assertServiceNotExists(t, new(codespace_model.ManagerToken), "user_id = ? AND token = ?", userID, userToken) + assertServiceExists(t, new(codespace_model.ManagerToken), "user_id = ? AND token = ?", 0, globalToken) + assertServiceNotExists(t, new(codespace_model.PermissionAuthorization), "id = ?", authorization.ID) + assertServiceNotExists(t, new(codespace_model.PermissionRepository), "id = ?", rule.ID) + assertServiceNotExists(t, new(codespace_model.UserSecret), "id = ?", secret.ID) + assertServiceNotExists(t, new(codespace_model.UserSecretRepository), "id = ?", secretRepository.ID) +} diff --git a/services/codespace/user_secret.go b/services/codespace/user_secret.go new file mode 100644 index 0000000000000..a6134f05fc8d6 --- /dev/null +++ b/services/codespace/user_secret.go @@ -0,0 +1,626 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "time" + "unicode/utf8" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + issues_model "gitea.dev/models/issues" + "gitea.dev/models/perm" + access_model "gitea.dev/models/perm/access" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unit" + user_model "gitea.dev/models/user" + "gitea.dev/modules/globallock" + secret_module "gitea.dev/modules/secret" + "gitea.dev/modules/setting" + "gitea.dev/modules/util" + repository_service "gitea.dev/services/repository" + secret_service "gitea.dev/services/secrets" +) + +const ( + userSecretCountLimit = 100 + userSecretValueSizeLimit = 48 * 1024 + repositorySecretTotalSizeLimit = 512 * 1024 + userSecretRepositorySearchSize = 20 +) + +var ( + ErrUserSecretNotFound = errors.New("codespace user secret not found") + ErrUserSecretNameInvalid = errors.New("codespace user secret name is invalid") + ErrUserSecretNameConflict = errors.New("codespace user secret name already exists") + ErrUserSecretValueInvalid = errors.New("codespace user secret value is invalid") + ErrUserSecretCountLimit = errors.New("codespace user secret count limit exceeded") + ErrUserSecretSizeLimit = errors.New("codespace user secret size limit exceeded") +) + +// UserSecretView is the value-free representation used by user settings. +type UserSecretView struct { + ID int64 + Name string + AllRepositories bool + Repositories []*repo_model.Repository + CreatedUnix int64 + UpdatedUnix int64 +} + +// SearchWritableSecretRepositories returns repositories eligible for a personal Codespace secret. +func SearchWritableSecretRepositories(ctx context.Context, user *user_model.User, keyword string) ([]*repo_model.Repository, error) { + if user == nil || user.ID <= 0 || user.Type != user_model.UserTypeIndividual { + return nil, util.NewInvalidArgumentErrorf("individual user is required") + } + result := make([]*repo_model.Repository, 0, userSecretRepositorySearchSize) + for page := 1; ; page++ { + repositories, total, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{Page: page, PageSize: 100}, + Actor: user, Keyword: strings.TrimSpace(keyword), Private: true, UnitType: unit.TypeCode, + }) + if err != nil { + return nil, err + } + for _, repo := range repositories { + allowed, err := userCanUseSecretRepository(ctx, user, repo) + if err != nil { + return nil, err + } + if allowed { + result = append(result, repo) + if len(result) == userSecretRepositorySearchSize { + return result, nil + } + } + } + if len(repositories) == 0 || int64(page*100) >= total { + return result, nil + } + } +} + +// RuntimeSecret is one environment variable made available to an authorized Codespace runtime. +type RuntimeSecret struct { + Name string + Value string +} + +// ListUserSecrets returns a user's secrets without decrypting their values. +func ListUserSecrets(ctx context.Context, userID int64) ([]UserSecretView, error) { + var secrets []*codespace_model.UserSecret + if err := db.GetEngine(ctx).Where("user_id = ?", userID).Asc("name").Find(&secrets); err != nil { + return nil, err + } + if len(secrets) == 0 { + return nil, nil + } + secretIDs := make([]int64, 0, len(secrets)) + for _, secret := range secrets { + secretIDs = append(secretIDs, secret.ID) + } + var bindings []*codespace_model.UserSecretRepository + if err := db.GetEngine(ctx).In("secret_id", secretIDs).Asc("secret_id", "repo_id").Find(&bindings); err != nil { + return nil, err + } + repoIDs := make([]int64, 0, len(bindings)) + for _, binding := range bindings { + repoIDs = append(repoIDs, binding.RepoID) + } + repoMap, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs) + if err != nil { + return nil, err + } + repositoryList := repo_model.RepositoryListOfMap(repoMap) + if err := repositoryList.LoadOwners(ctx); err != nil { + return nil, err + } + bindingsBySecret := make(map[int64][]*repo_model.Repository, len(secrets)) + for _, binding := range bindings { + if repo := repoMap[binding.RepoID]; repo != nil { + bindingsBySecret[binding.SecretID] = append(bindingsBySecret[binding.SecretID], repo) + } + } + views := make([]UserSecretView, 0, len(secrets)) + for _, secret := range secrets { + selectedRepositories := bindingsBySecret[secret.ID] + slices.SortFunc(selectedRepositories, func(a, b *repo_model.Repository) int { + return strings.Compare(a.FullName(), b.FullName()) + }) + views = append(views, UserSecretView{ + ID: secret.ID, Name: secret.Name, AllRepositories: secret.AllRepositories, Repositories: selectedRepositories, + CreatedUnix: secret.CreatedUnix, UpdatedUnix: secret.UpdatedUnix, + }) + } + return views, nil +} + +// CreateUserSecret creates a secret with an independently selected repository scope. +func CreateUserSecret(ctx context.Context, user *user_model.User, name, value string, allRepositories bool, repoIDs []int64) error { + if user == nil || user.ID <= 0 { + return util.NewInvalidArgumentErrorf("user is required") + } + repoIDs, err := normalizeUserSecretRepositoryIDs(repoIDs) + if err != nil { + return err + } + if allRepositories { + repoIDs = nil + } + return globallock.LockAndDo(ctx, codespaceUserRelationLockKey(user.ID), func(ctx context.Context) error { + return withUserSecretRepositoryLocks(ctx, repoIDs, func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + currentUser, repositories, err := loadWritableSecretRepositories(ctx, user.ID, repoIDs) + if err != nil { + return err + } + return insertUserSecret(ctx, currentUser, name, value, allRepositories, repositories) + }) + }) + }) +} + +// UpdateUserSecretValue replaces a secret value without changing its repository selection. +func UpdateUserSecretValue(ctx context.Context, userID, secretID int64, value string) error { + if err := validateUserSecretValue(value); err != nil { + return err + } + encrypted, err := secret_module.EncryptSecret(setting.SecretKey, value) + if err != nil { + return err + } + return globallock.LockAndDo(ctx, codespaceUserRelationLockKey(userID), func(ctx context.Context) error { + _, err := getUserSecret(ctx, userID, secretID) + if err != nil { + return err + } + repoIDs, err := getUserSecretRepositoryIDs(ctx, secretID) + if err != nil { + return err + } + return withUserSecretRepositoryLocks(ctx, repoIDs, func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + secret, err := getUserSecret(ctx, userID, secretID) + if err != nil { + return err + } + repoIDs, err := getUserSecretRepositoryIDs(ctx, secretID) + if err != nil { + return err + } + if err := checkUserSecretScopeSize(ctx, userID, secretID, int64(len(value)), secret.AllRepositories, repoIDs); err != nil { + return err + } + secret.DataEncrypted = encrypted + secret.DataSize = int64(len(value)) + secret.UpdatedUnix = time.Now().Unix() + _, err = db.GetEngine(ctx).ID(secretID).Cols("data_encrypted", "data_size", "updated_unix").Update(secret) + return err + }) + }) + }) +} + +// UpdateUserSecretRepositoryAccess replaces the complete repository scope for one secret. +func UpdateUserSecretRepositoryAccess(ctx context.Context, user *user_model.User, secretID int64, allRepositories bool, repoIDs []int64) error { + if user == nil || user.ID <= 0 { + return util.NewInvalidArgumentErrorf("user is required") + } + repoIDs, err := normalizeUserSecretRepositoryIDs(repoIDs) + if err != nil { + return err + } + if allRepositories { + repoIDs = nil + } + return globallock.LockAndDo(ctx, codespaceUserRelationLockKey(user.ID), func(ctx context.Context) error { + _, err := getUserSecret(ctx, user.ID, secretID) + if err != nil { + return err + } + existingRepoIDs, err := getUserSecretRepositoryIDs(ctx, secretID) + if err != nil { + return err + } + lockedRepoIDs := slices.Clone(repoIDs) + lockedRepoIDs = append(lockedRepoIDs, existingRepoIDs...) + return withUserSecretRepositoryLocks(ctx, lockedRepoIDs, func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + currentUser, repositories, err := loadWritableSecretRepositories(ctx, user.ID, repoIDs) + if err != nil { + return err + } + secret, err := getUserSecret(ctx, currentUser.ID, secretID) + if err != nil { + return err + } + if err := checkUserSecretScopeSize(ctx, currentUser.ID, secret.ID, secret.DataSize, allRepositories, repoIDs); err != nil { + return err + } + if _, err := db.GetEngine(ctx).Where("secret_id = ?", secretID).Delete(new(codespace_model.UserSecretRepository)); err != nil { + return err + } + if err := insertUserSecretRepositoryBindings(ctx, secretID, repositories); err != nil { + return err + } + secret.AllRepositories = allRepositories + secret.UpdatedUnix = time.Now().Unix() + _, err = db.GetEngine(ctx).ID(secretID).Cols("all_repositories", "updated_unix").Update(secret) + return err + }) + }) + }) +} + +// DeleteUserSecret removes a secret and every repository binding. +func DeleteUserSecret(ctx context.Context, userID, secretID int64) error { + return globallock.LockAndDo(ctx, codespaceUserRelationLockKey(userID), func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + if _, err := getUserSecret(ctx, userID, secretID); err != nil { + return err + } + if _, err := db.GetEngine(ctx).Where("secret_id = ?", secretID).Delete(new(codespace_model.UserSecretRepository)); err != nil { + return err + } + _, err := db.GetEngine(ctx).ID(secretID).Delete(new(codespace_model.UserSecret)) + return err + }) + }) +} + +func resolveCodespaceRuntimeSecrets(ctx context.Context, user *user_model.User, codespace *codespace_model.Codespace) ([]RuntimeSecret, error) { + if codespace.RepoID <= 0 { + return nil, nil + } + repo, err := repo_model.GetRepositoryByID(ctx, codespace.RepoID) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + return nil, nil + } + return nil, err + } + allowed, err := userCanUseSecretRepository(ctx, user, repo) + if err != nil || !allowed { + return nil, err + } + if codespace.RefType == "pull" { + pullName, hasPrefix := strings.CutPrefix(codespace.RefName, "refs/pull/") + pullName, hasSuffix := strings.CutSuffix(pullName, "/head") + pullIndex, err := strconv.ParseInt(pullName, 10, 64) + if !hasPrefix || !hasSuffix || err != nil || pullIndex <= 0 { + return nil, fmt.Errorf("invalid codespace pull ref %q", codespace.RefName) + } + pull, err := issues_model.GetPullRequestByIndex(ctx, repo.ID, pullIndex) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + return nil, nil + } + return nil, err + } + if pull.HeadRepoID != pull.BaseRepoID { + return nil, nil + } + } + return resolveUserSecretsForRepository(ctx, user.ID, repo.ID) +} + +func resolveUserSecretsForRepository(ctx context.Context, userID, repoID int64) ([]RuntimeSecret, error) { + var secrets []*codespace_model.UserSecret + if err := db.GetEngine(ctx).Table("codespace_user_secret"). + Where("codespace_user_secret.user_id = ? AND (codespace_user_secret.all_repositories = ? OR EXISTS (SELECT 1 FROM codespace_user_secret_repository WHERE codespace_user_secret_repository.secret_id = codespace_user_secret.id AND codespace_user_secret_repository.repo_id = ?))", userID, true, repoID). + Asc("codespace_user_secret.name").Find(&secrets); err != nil { + return nil, err + } + result := make([]RuntimeSecret, 0, len(secrets)) + var totalSize int64 + for _, secret := range secrets { + value, err := secret_module.DecryptSecret(setting.SecretKey, secret.DataEncrypted) + if err != nil { + return nil, err + } + if err := validateUserSecret(secret.Name, value); err != nil { + return nil, err + } + totalSize += int64(len(value)) + if totalSize > repositorySecretTotalSizeLimit { + return nil, ErrUserSecretSizeLimit + } + result = append(result, RuntimeSecret{Name: secret.Name, Value: value}) + } + return result, nil +} + +func configureRecommendedSecrets(ctx context.Context, user *user_model.User, repo *repo_model.Repository, recommendations []CreateRecommendedSecret, values map[string]string, enabled map[string]bool) error { + needsWrite := false + for _, recommendation := range recommendations { + if !recommendation.Available && (recommendation.Configured && enabled[recommendation.Name] || !recommendation.Configured && values[recommendation.Name] != "") { + needsWrite = true + break + } + } + if !needsWrite { + return nil + } + allowed, err := userCanUseSecretRepository(ctx, user, repo) + if err != nil { + return err + } + if !allowed { + return util.NewPermissionDeniedErrorf("code write access is required") + } + for _, recommendation := range recommendations { + if recommendation.Available { + continue + } + if recommendation.Configured { + if !enabled[recommendation.Name] { + continue + } + secret := new(codespace_model.UserSecret) + has, err := db.GetEngine(ctx).Where("user_id = ? AND name = ?", user.ID, recommendation.Name).Get(secret) + if err != nil { + return err + } + if !has { + return ErrUserSecretNotFound + } + if secret.AllRepositories { + continue + } + if err := bindUserSecretToRepository(ctx, repo, secret); err != nil { + return err + } + continue + } + if value := values[recommendation.Name]; value != "" { + if err := insertUserSecret(ctx, user, recommendation.Name, value, false, []*repo_model.Repository{repo}); err != nil { + return err + } + } + } + return nil +} + +func validateUserSecret(name, value string) error { + if err := validateUserSecretName(name); err != nil { + return err + } + return validateUserSecretValue(value) +} + +func validateUserSecretValue(value string) error { + if value == "" || len(value) > userSecretValueSizeLimit || !utf8.ValidString(value) || strings.IndexByte(value, 0) >= 0 { + return fmt.Errorf("%w: %w", ErrUserSecretValueInvalid, util.ErrInvalidArgument) + } + return nil +} + +func validateUserSecretName(name string) error { + if err := secret_service.ValidateName(name); err != nil { + return fmt.Errorf("%w: %v", ErrUserSecretNameInvalid, err) + } + if strings.HasPrefix(name, "CODESPACE_") || name == "HOME" || name == "USER" || name == "LOGNAME" || name == "SHELL" || name == "PATH" || name == "TERM" || name == "COLORTERM" { + return fmt.Errorf("%w: reserved name", ErrUserSecretNameInvalid) + } + return nil +} + +func checkUserSecretScopeSize(ctx context.Context, userID, excludedSecretID, newSize int64, allRepositories bool, repoIDs []int64) error { + var secrets []*codespace_model.UserSecret + query := db.GetEngine(ctx).Where("user_id = ?", userID) + if excludedSecretID > 0 { + query = query.And("id <> ?", excludedSecretID) + } + if err := query.Find(&secrets); err != nil { + return err + } + secretSizes := make(map[int64]int64, len(secrets)) + selectedSecretIDs := make([]int64, 0, len(secrets)) + var allRepositoriesTotal int64 + for _, secret := range secrets { + secretSizes[secret.ID] = secret.DataSize + if secret.AllRepositories { + allRepositoriesTotal += secret.DataSize + } else { + selectedSecretIDs = append(selectedSecretIDs, secret.ID) + } + } + if allRepositories { + allRepositoriesTotal += newSize + } + if allRepositoriesTotal > repositorySecretTotalSizeLimit { + return ErrUserSecretSizeLimit + } + totalsByRepository := make(map[int64]int64) + if len(selectedSecretIDs) > 0 { + var bindings []*codespace_model.UserSecretRepository + if err := db.GetEngine(ctx).In("secret_id", selectedSecretIDs).Find(&bindings); err != nil { + return err + } + for _, binding := range bindings { + totalsByRepository[binding.RepoID] += secretSizes[binding.SecretID] + } + } + if !allRepositories { + for _, repoID := range repoIDs { + totalsByRepository[repoID] += newSize + } + } + for _, selectedTotal := range totalsByRepository { + if allRepositoriesTotal+selectedTotal > repositorySecretTotalSizeLimit { + return ErrUserSecretSizeLimit + } + } + return nil +} + +func getUserSecret(ctx context.Context, userID, secretID int64) (*codespace_model.UserSecret, error) { + secret := new(codespace_model.UserSecret) + has, err := db.GetEngine(ctx).ID(secretID).Where("user_id = ?", userID).Get(secret) + if err != nil { + return nil, err + } + if !has { + return nil, ErrUserSecretNotFound + } + return secret, nil +} + +func loadWritableSecretRepositories(ctx context.Context, userID int64, repoIDs []int64) (*user_model.User, []*repo_model.Repository, error) { + user, err := user_model.GetUserByID(ctx, userID) + if err != nil { + return nil, nil, err + } + if user.Type != user_model.UserTypeIndividual { + return nil, nil, util.NewInvalidArgumentErrorf("individual user is required") + } + if len(repoIDs) == 0 { + return user, nil, nil + } + repoMap, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs) + if err != nil { + return nil, nil, err + } + if len(repoMap) != len(repoIDs) { + return nil, nil, repo_model.ErrRepoNotExist{} + } + repositories := make([]*repo_model.Repository, 0, len(repoIDs)) + for _, repoID := range repoIDs { + repo := repoMap[repoID] + allowed, err := userCanUseSecretRepository(ctx, user, repo) + if err != nil { + return nil, nil, err + } + if !allowed { + return nil, nil, util.NewPermissionDeniedErrorf("code write access is required") + } + repositories = append(repositories, repo) + } + return user, repositories, nil +} + +func insertUserSecret(ctx context.Context, user *user_model.User, name, value string, allRepositories bool, repositories []*repo_model.Repository) error { + name = strings.ToUpper(strings.TrimSpace(name)) + if err := validateUserSecret(name, value); err != nil { + return err + } + has, err := db.GetEngine(ctx).Where("user_id = ? AND name = ?", user.ID, name).Exist(new(codespace_model.UserSecret)) + if err != nil { + return err + } + if has { + return ErrUserSecretNameConflict + } + count, err := db.GetEngine(ctx).Where("user_id = ?", user.ID).Count(new(codespace_model.UserSecret)) + if err != nil { + return err + } + if count >= userSecretCountLimit { + return ErrUserSecretCountLimit + } + repoIDs := make([]int64, 0, len(repositories)) + for _, repo := range repositories { + repoIDs = append(repoIDs, repo.ID) + } + if err := checkUserSecretScopeSize(ctx, user.ID, 0, int64(len(value)), allRepositories, repoIDs); err != nil { + return err + } + encrypted, err := secret_module.EncryptSecret(setting.SecretKey, value) + if err != nil { + return err + } + now := time.Now().Unix() + secret := &codespace_model.UserSecret{ + UserID: user.ID, Name: name, DataEncrypted: encrypted, DataSize: int64(len(value)), + AllRepositories: allRepositories, CreatedUnix: now, UpdatedUnix: now, + } + if err := db.Insert(ctx, secret); err != nil { + return err + } + return insertUserSecretRepositoryBindings(ctx, secret.ID, repositories) +} + +func bindUserSecretToRepository(ctx context.Context, repo *repo_model.Repository, secret *codespace_model.UserSecret) error { + if secret.AllRepositories { + return nil + } + has, err := db.GetEngine(ctx).Where("secret_id = ? AND repo_id = ?", secret.ID, repo.ID).Exist(new(codespace_model.UserSecretRepository)) + if err != nil || has { + return err + } + var bindings []*codespace_model.UserSecretRepository + if err := db.GetEngine(ctx).Where("secret_id = ?", secret.ID).Find(&bindings); err != nil { + return err + } + repoIDs := make([]int64, 0, len(bindings)+1) + for _, binding := range bindings { + repoIDs = append(repoIDs, binding.RepoID) + } + repoIDs = append(repoIDs, repo.ID) + if err := checkUserSecretScopeSize(ctx, secret.UserID, secret.ID, secret.DataSize, false, repoIDs); err != nil { + return err + } + return db.Insert(ctx, &codespace_model.UserSecretRepository{SecretID: secret.ID, RepoID: repo.ID}) +} + +func getUserSecretRepositoryIDs(ctx context.Context, secretID int64) ([]int64, error) { + var bindings []*codespace_model.UserSecretRepository + if err := db.GetEngine(ctx).Where("secret_id = ?", secretID).Find(&bindings); err != nil { + return nil, err + } + repoIDs := make([]int64, 0, len(bindings)) + for _, binding := range bindings { + repoIDs = append(repoIDs, binding.RepoID) + } + return repoIDs, nil +} + +func insertUserSecretRepositoryBindings(ctx context.Context, secretID int64, repositories []*repo_model.Repository) error { + for _, repo := range repositories { + if err := db.Insert(ctx, &codespace_model.UserSecretRepository{SecretID: secretID, RepoID: repo.ID}); err != nil { + return err + } + } + return nil +} + +func normalizeUserSecretRepositoryIDs(repoIDs []int64) ([]int64, error) { + repoIDs = slices.Clone(repoIDs) + for _, repoID := range repoIDs { + if repoID <= 0 { + return nil, util.NewInvalidArgumentErrorf("invalid repository") + } + } + slices.Sort(repoIDs) + return slices.Compact(repoIDs), nil +} + +func userCanUseSecretRepository(ctx context.Context, user *user_model.User, repo *repo_model.Repository) (bool, error) { + if user == nil || user.ID <= 0 || user.Type != user_model.UserTypeIndividual || repo == nil || repo.ID <= 0 { + return false, nil + } + return access_model.HasAccessUnit(ctx, user, repo, unit.TypeCode, perm.AccessModeWrite) +} + +func withUserSecretRepositoryLocks(ctx context.Context, repoIDs []int64, fn func(context.Context) error) error { + repoIDs = append([]int64(nil), repoIDs...) + slices.Sort(repoIDs) + repoIDs = slices.Compact(repoIDs) + var lockNext func(context.Context, int) error + lockNext = func(ctx context.Context, index int) error { + if index == len(repoIDs) { + return fn(ctx) + } + return globallock.LockAndDo(ctx, repository_service.WorkingLockKey(repoIDs[index]), func(ctx context.Context) error { + return lockNext(ctx, index+1) + }) + } + return lockNext(ctx, 0) +} diff --git a/services/codespace/user_secret_test.go b/services/codespace/user_secret_test.go new file mode 100644 index 0000000000000..ea9932ce5ef3b --- /dev/null +++ b/services/codespace/user_secret_test.go @@ -0,0 +1,128 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "strconv" + "strings" + "testing" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserSecretRepositoryScopeAndValueUpdate(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + require.NoError(t, CreateUserSecret(t.Context(), user, "database_password", "initial-value", false, []int64{repo.ID})) + + views, err := ListUserSecrets(t.Context(), user.ID) + require.NoError(t, err) + require.Len(t, views, 1) + assert.Equal(t, "DATABASE_PASSWORD", views[0].Name) + require.Len(t, views[0].Repositories, 1) + assert.Equal(t, repo.ID, views[0].Repositories[0].ID) + + resolved, err := resolveUserSecretsForRepository(t.Context(), user.ID, repo.ID) + require.NoError(t, err) + assert.Equal(t, []RuntimeSecret{{Name: "DATABASE_PASSWORD", Value: "initial-value"}}, resolved) + + otherRepoSecrets, err := resolveUserSecretsForRepository(t.Context(), user.ID, 2) + require.NoError(t, err) + assert.Empty(t, otherRepoSecrets) + + require.NoError(t, UpdateUserSecretValue(t.Context(), user.ID, views[0].ID, "updated-value")) + resolved, err = resolveUserSecretsForRepository(t.Context(), user.ID, repo.ID) + require.NoError(t, err) + assert.Equal(t, "updated-value", resolved[0].Value) +} + +func TestUserSecretRepositoryAccessModes(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + require.NoError(t, CreateUserSecret(t.Context(), user, "personal_token", "secret-value", false, nil)) + views, err := ListUserSecrets(t.Context(), user.ID) + require.NoError(t, err) + require.Len(t, views, 1) + assert.False(t, views[0].AllRepositories) + assert.Empty(t, views[0].Repositories) + + require.NoError(t, UpdateUserSecretRepositoryAccess(t.Context(), user, views[0].ID, true, nil)) + resolved, err := resolveUserSecretsForRepository(t.Context(), user.ID, 2) + require.NoError(t, err) + assert.Equal(t, []RuntimeSecret{{Name: "PERSONAL_TOKEN", Value: "secret-value"}}, resolved) + recommendations, available, err := resolveCreateSecrets(t.Context(), user.ID, 2, []CreateRecommendedSecret{{Name: "PERSONAL_TOKEN"}}) + require.NoError(t, err) + assert.True(t, recommendations[0].Configured) + assert.True(t, recommendations[0].Available) + assert.Equal(t, []CreateSecretSummary{{Name: "PERSONAL_TOKEN"}}, available) + + require.NoError(t, UpdateUserSecretRepositoryAccess(t.Context(), user, views[0].ID, false, []int64{1})) + resolved, err = resolveUserSecretsForRepository(t.Context(), user.ID, 2) + require.NoError(t, err) + assert.Empty(t, resolved) + resolved, err = resolveUserSecretsForRepository(t.Context(), user.ID, 1) + require.NoError(t, err) + assert.Len(t, resolved, 1) +} + +func TestUserSecretMutationsRequireOwner(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + otherUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + require.NoError(t, CreateUserSecret(t.Context(), owner, "owner_secret", "owner-value", false, []int64{1})) + views, err := ListUserSecrets(t.Context(), owner.ID) + require.NoError(t, err) + require.Len(t, views, 1) + secretID := views[0].ID + + require.ErrorIs(t, UpdateUserSecretValue(t.Context(), otherUser.ID, secretID, "changed"), ErrUserSecretNotFound) + require.ErrorIs(t, UpdateUserSecretRepositoryAccess(t.Context(), otherUser, secretID, true, nil), ErrUserSecretNotFound) + require.ErrorIs(t, DeleteUserSecret(t.Context(), otherUser.ID, secretID), ErrUserSecretNotFound) + + resolved, err := resolveUserSecretsForRepository(t.Context(), owner.ID, 1) + require.NoError(t, err) + assert.Equal(t, []RuntimeSecret{{Name: "OWNER_SECRET", Value: "owner-value"}}, resolved) +} + +func TestUserSecretSizeLimitIsPerUser(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + otherSecret := &codespace_model.UserSecret{UserID: 1, Name: "OTHER_USER_SECRET", DataEncrypted: "unused", DataSize: repositorySecretTotalSizeLimit} + require.NoError(t, db.Insert(t.Context(), otherSecret)) + require.NoError(t, db.Insert(t.Context(), &codespace_model.UserSecretRepository{SecretID: otherSecret.ID, RepoID: 1})) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + require.NoError(t, CreateUserSecret(t.Context(), user, "own_secret", strings.Repeat("x", 64), false, []int64{1})) +} + +func TestUserSecretValidationErrors(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + require.ErrorIs(t, CreateUserSecret(t.Context(), user, "invalid_value", "", false, nil), ErrUserSecretValueInvalid) + require.NoError(t, CreateUserSecret(t.Context(), user, "duplicate_name", "first", false, nil)) + require.ErrorIs(t, CreateUserSecret(t.Context(), user, "duplicate_name", "second", false, nil), ErrUserSecretNameConflict) +} + +func TestUserSecretAllRepositoriesSizeLimit(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + value := strings.Repeat("x", userSecretValueSizeLimit) + for i := range repositorySecretTotalSizeLimit / userSecretValueSizeLimit { + require.NoError(t, CreateUserSecret(t.Context(), user, "secret_"+strconv.Itoa(i), value, true, nil)) + } + require.ErrorIs(t, CreateUserSecret(t.Context(), user, "one_too_many", value, true, nil), ErrUserSecretSizeLimit) +} diff --git a/services/codespace/view.go b/services/codespace/view.go new file mode 100644 index 0000000000000..b97520000ca27 --- /dev/null +++ b/services/codespace/view.go @@ -0,0 +1,572 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" + "gitea.dev/modules/setting" +) + +const ( + // DisplayQueued means a create operation is waiting for a Manager. + DisplayQueued = "queued" + // DisplayBooting means a Manager is creating or resuming the runtime. + DisplayBooting = "booting" + // DisplayRunning means the runtime is currently interactive. + DisplayRunning = "running" + // DisplayStopping means a stop operation is active. + DisplayStopping = "stopping" + // DisplayStopped means the runtime is stopped and can be resumed. + DisplayStopped = "stopped" + // DisplayResuming means a resume operation is active. + DisplayResuming = "resuming" + // DisplayDeleting means a delete operation is active. + DisplayDeleting = "deleting" + // DisplayFailed means lifecycle processing reached a failed terminal state. + DisplayFailed = "failed" + // DisplayMetadataRebuilding means the runtime is running but ready metadata is absent. + DisplayMetadataRebuilding = "metadata_rebuilding" + // DisplayRecovering means Gitea is waiting for a running Manager to become usable again. + DisplayRecovering = "recovering" + + // DetailModeOverview shows stable runtime access and configuration. + DetailModeOverview = "overview" + // DetailModeLogs shows lifecycle progress and operation output. + DetailModeLogs = "logs" +) + +const ( + refreshTransitionMilliseconds = 2000 + refreshStableMilliseconds = 15000 +) + +var ( + // ErrViewNotFound is returned when the Codespace cannot be found. + ErrViewNotFound = errors.New("codespace view not found") + // ErrViewPermissionDenied is returned when the caller is not the creator. + ErrViewPermissionDenied = errors.New("codespace view permission denied") +) + +// CreatorListOptions selects creator-owned Codespaces for a list page. +type CreatorListOptions struct { + UserID int64 + RepoID int64 + RepoOwnerID int64 + RefType string + RefName string + CommitSHA string + Page int + PageSize int + Limit int +} + +// CreatorDetailOptions selects one creator-owned Codespace for a detail page. +type CreatorDetailOptions struct { + UserID int64 + CodespaceUUID string +} + +// CreatorCodespaceList contains rows for a creator list page. +type CreatorCodespaceList struct { + Rows []*CreatorCodespaceView + Total int64 +} + +// CreatorCodespaceView contains the server-authoritative presentation state. +type CreatorCodespaceView struct { + UUID string + ShortUUID string + RepoID int64 + RepoFullName string + RepoLink string + CommitLink string + RefType string + RefName string + RefDisplayName string + CommitSHA string + EnvironmentTag string + Status string + DisplayStatus string + DetailMode string + BootStageKey string + DisplayStatusKey string + StatusSummary string + StatusSummaryKey string + LastActiveUnix int64 + CreatedUnix int64 + UpdatedUnix int64 + AutoStop CreatorAutoStopView + RefreshAfterMillis int + Workspace *CreatorEndpointView + Endpoints []CreatorEndpointView + SSH *CreatorSSHView + ResourceUsage *CreatorResourceUsageView + CanOpen bool + CanContinue bool + CanStop bool + CanResume bool + CanDelete bool + CanConfigureAutoStop bool +} + +// CreatorAutoStopView contains the persisted and effective auto-stop settings shown to the creator. +type CreatorAutoStopView struct { + Mode string + Timeout CreatorDurationView + Default CreatorDurationView + Minimum CreatorDurationView + Maximum CreatorDurationView + EffectiveEnabled bool + EffectiveTimeout CreatorDurationView + CustomTimeoutOutOfRange bool +} + +// CreatorDurationView contains an exact duration split into a form value and unit. +type CreatorDurationView struct { + Value int64 + Unit string + TranslationKey string +} + +// CreatorEndpointView contains one current open target shown on the detail page. +type CreatorEndpointView struct { + EndpointID string + Label string + Port uint16 + Public bool + OpenPath string + CanOpen bool +} + +// CreatorSSHView contains the current SSH command shown on the detail page. +type CreatorSSHView struct { + Command string + HostKeyAlgorithm string + HostKeyFingerprint string + HostKeyUpdatedUnix int64 +} + +// CreatorResourceMetricView contains one runtime resource usage measurement. +type CreatorResourceMetricView struct { + Used int64 + Limit int64 + UsedDisplay string + LimitDisplay string +} + +// CreatorResourceUsageView contains the latest runtime resource usage summary. +type CreatorResourceUsageView struct { + CPU CreatorResourceMetricView + Memory CreatorResourceMetricView + Disk CreatorResourceMetricView + ObservedUnix int64 +} + +type creatorViewCache struct { + repositories map[int64]*repo_model.Repository + managers map[int64]*codespace_model.Manager +} + +// ListCreatorCodespaces returns creator-owned Codespaces for list pages. +func ListCreatorCodespaces(ctx context.Context, opts CreatorListOptions) (*CreatorCodespaceList, error) { + if opts.UserID <= 0 { + return nil, errors.New("user_id must be positive") + } + query := db.GetEngine(ctx).Table("codespace").Select("codespace.*").Where("codespace.user_id = ?", opts.UserID) + if opts.RepoID > 0 { + query.And("codespace.repo_id = ?", opts.RepoID) + } + if opts.RepoOwnerID > 0 { + query.Join("INNER", "repository", "repository.id = codespace.repo_id AND repository.owner_id = ?", opts.RepoOwnerID) + } + if opts.RefType != "" || opts.RefName != "" { + if opts.RefType == "" || opts.RefName == "" { + return nil, errors.New("ref_type and ref_name must be provided together") + } + if opts.CommitSHA != "" { + return nil, errors.New("commit_sha cannot be combined with ref_type and ref_name") + } + query.And("codespace.ref_type = ? AND codespace.ref_name = ?", opts.RefType, opts.RefName) + } else if opts.CommitSHA != "" { + query.And("codespace.commit_sha = ?", opts.CommitSHA) + } + if opts.PageSize > 0 { + if opts.Page <= 0 { + opts.Page = 1 + } + query.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize) + } else if opts.Limit > 0 { + query.Limit(opts.Limit) + } + var rows []*codespace_model.Codespace + query.Desc("codespace.updated_unix", "codespace.created_unix", "codespace.uuid") + var total int64 + if opts.PageSize > 0 { + var err error + total, err = query.FindAndCount(&rows) + if err != nil { + return nil, err + } + } else if err := query.Find(&rows); err != nil { + return nil, err + } + cache, err := loadCreatorViewCache(ctx, rows) + if err != nil { + return nil, err + } + result := &CreatorCodespaceList{Rows: make([]*CreatorCodespaceView, 0, len(rows)), Total: total} + for _, row := range rows { + view, err := creatorCodespaceView(ctx, row, cache, false) + if err != nil { + return nil, err + } + result.Rows = append(result.Rows, view) + } + return result, nil +} + +// GetCreatorCodespace returns one creator-owned Codespace for the detail page. +func GetCreatorCodespace(ctx context.Context, opts CreatorDetailOptions) (*CreatorCodespaceView, error) { + if opts.UserID <= 0 { + return nil, errors.New("user_id must be positive") + } + if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { + return nil, err + } + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceUUID).Get(codespace) + if err != nil { + return nil, err + } + if !has { + return nil, ErrViewNotFound + } + if codespace.UserID != opts.UserID { + return nil, ErrViewPermissionDenied + } + cache, err := loadCreatorViewCache(ctx, []*codespace_model.Codespace{codespace}) + if err != nil { + return nil, err + } + return creatorCodespaceView(ctx, codespace, cache, true) +} + +func creatorCodespaceView(ctx context.Context, codespace *codespace_model.Codespace, cache *creatorViewCache, includeDetailData bool) (*CreatorCodespaceView, error) { + refDisplayName := codespace.RefName + if codespace.RefType == "pull" { + if index, err := pullIndexFromCanonicalRef(codespace.RefName); err == nil { + refDisplayName = fmt.Sprintf("#%d", index) + } + } + view := &CreatorCodespaceView{ + UUID: codespace.UUID, + ShortUUID: shortCodespaceUUID(codespace.UUID), + RepoID: codespace.RepoID, + RefType: codespace.RefType, + RefName: codespace.RefName, + RefDisplayName: refDisplayName, + CommitSHA: codespace.CommitSHA, + EnvironmentTag: codespace.EnvironmentTag, + Status: codespace.Status, + LastActiveUnix: codespace.LastActiveUnix, + CreatedUnix: codespace.CreatedUnix, + UpdatedUnix: codespace.UpdatedUnix, + AutoStop: creatorAutoStopView(codespace), + } + if codespace.RepoID > 0 { + if repo := cache.repositories[codespace.RepoID]; repo != nil { + view.RepoFullName = repo.FullName() + view.RepoLink = repo.Link() + view.CommitLink = repo.CommitLink(codespace.CommitSHA) + } + } + manager := cache.managers[codespace.ManagerID] + applyCreatorDisplayState(ctx, codespace, view, manager, includeDetailData) + switch view.DisplayStatus { + case DisplayRunning, DisplayStopped, DisplayRecovering, DisplayMetadataRebuilding: + view.DetailMode = DetailModeOverview + default: + view.DetailMode = DetailModeLogs + } + if includeDetailData && (view.DisplayStatus == DisplayBooting || view.DisplayStatus == DisplayResuming) { + entry, hasEntry, err := getRuntimeMetadataEntry(codespace.UUID) + if err == nil && hasEntry && entry.Metadata.Boot.OperationRVersion == codespace.OperationRVersion { + switch entry.Metadata.Boot.Stage { + case bootStagePrepareRuntime, bootStageInitializeSystem, bootStagePrepareWorkspace, bootStageStartEnvironment, bootStagePublishReady, bootStageReady: + view.BootStageKey = "codespace.boot_stage." + strings.ReplaceAll(entry.Metadata.Boot.Stage, "-", "_") + } + } + } + applyCreatorActions(codespace, view, manager) + return view, nil +} + +func loadCreatorViewCache(ctx context.Context, codespaces []*codespace_model.Codespace) (*creatorViewCache, error) { + repoIDs := make([]int64, 0, len(codespaces)) + managerIDs := make([]int64, 0, len(codespaces)) + seenRepos := make(map[int64]struct{}, len(codespaces)) + seenManagers := make(map[int64]struct{}, len(codespaces)) + for _, codespace := range codespaces { + if codespace.RepoID > 0 { + if _, seen := seenRepos[codespace.RepoID]; !seen { + repoIDs = append(repoIDs, codespace.RepoID) + seenRepos[codespace.RepoID] = struct{}{} + } + } + if codespace.ManagerID > 0 { + if _, seen := seenManagers[codespace.ManagerID]; !seen { + managerIDs = append(managerIDs, codespace.ManagerID) + seenManagers[codespace.ManagerID] = struct{}{} + } + } + } + + repositories, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs) + if err != nil { + return nil, err + } + managers := make(map[int64]*codespace_model.Manager, len(managerIDs)) + if len(managerIDs) > 0 { + var rows []*codespace_model.Manager + if err := db.GetEngine(ctx).In("id", managerIDs).Find(&rows); err != nil { + return nil, err + } + for _, manager := range rows { + managers[manager.ID] = manager + } + } + return &creatorViewCache{repositories: repositories, managers: managers}, nil +} + +func creatorAutoStopView(codespace *codespace_model.Codespace) CreatorAutoStopView { + defaultSeconds := int64(setting.Codespace.AutoStopDefaultTimeout / time.Second) + minimumSeconds := int64(setting.Codespace.AutoStopMinTimeout / time.Second) + maximumSeconds := int64(setting.Codespace.AutoStopMaxTimeout / time.Second) + timeoutSeconds := defaultSeconds + if codespace.AutoStopMode == codespace_model.AutoStopModeCustom { + timeoutSeconds = codespace.AutoStopTimeoutSeconds + } + effective := effectiveRuntimeSettings(codespace) + return CreatorAutoStopView{ + Mode: codespace.AutoStopMode, + Timeout: creatorDurationView(timeoutSeconds), + Default: creatorDurationView(defaultSeconds), + Minimum: creatorDurationView(minimumSeconds), + Maximum: creatorDurationView(maximumSeconds), + EffectiveEnabled: effective.AutoStopEnabled, + EffectiveTimeout: creatorDurationView(effective.IdleTimeoutSeconds), + CustomTimeoutOutOfRange: codespace.AutoStopMode == codespace_model.AutoStopModeCustom && (codespace.AutoStopTimeoutSeconds < minimumSeconds || codespace.AutoStopTimeoutSeconds > maximumSeconds), + } +} + +func creatorDurationView(seconds int64) CreatorDurationView { + units := []struct { + name string + translationKey string + seconds int64 + }{ + {"days", "tool.days", 24 * 60 * 60}, + {"hours", "tool.hours", 60 * 60}, + {"minutes", "tool.minutes", 60}, + } + for _, unit := range units { + if seconds > 0 && seconds%unit.seconds == 0 { + return CreatorDurationView{Value: seconds / unit.seconds, Unit: unit.name, TranslationKey: unit.translationKey} + } + } + return CreatorDurationView{Value: seconds, Unit: "seconds", TranslationKey: "tool.seconds"} +} + +func applyCreatorDisplayState(ctx context.Context, codespace *codespace_model.Codespace, view *CreatorCodespaceView, manager *codespace_model.Manager, includeDetailData bool) { + view.DisplayStatus = codespace.Status + switch codespace.Status { + case codespace_model.StatusCreating: + if codespace.OperationStatus == codespace_model.OperationStatusQueued { + view.DisplayStatus = DisplayQueued + } else { + view.DisplayStatus = DisplayBooting + } + case codespace_model.StatusRunning: + view.DisplayStatus = runningDisplayStatus(ctx, codespace, view, manager, includeDetailData) + case codespace_model.StatusStopped: + if codespace.OperationType == codespace_model.OperationResume { + view.DisplayStatus = DisplayResuming + } + case codespace_model.StatusDeleting: + view.DisplayStatus = DisplayDeleting + case codespace_model.StatusFailed: + view.DisplayStatus = DisplayFailed + } + view.RefreshAfterMillis = refreshStableMilliseconds + switch view.DisplayStatus { + case DisplayQueued, DisplayBooting, DisplayStopping, DisplayResuming, DisplayDeleting, DisplayMetadataRebuilding, DisplayRecovering: + view.RefreshAfterMillis = refreshTransitionMilliseconds + } + view.StatusSummary = statusSummary(view.DisplayStatus) + view.DisplayStatusKey = "codespace.status." + view.DisplayStatus + view.StatusSummaryKey = "codespace.status_summary." + view.DisplayStatus +} + +func runningDisplayStatus(ctx context.Context, codespace *codespace_model.Codespace, view *CreatorCodespaceView, manager *codespace_model.Manager, includeDetailData bool) string { + if codespace.OperationType == codespace_model.OperationStop && !isQueuedIdleStop(codespace) { + return DisplayStopping + } + if manager == nil || manager.RuntimeState != codespace_model.ManagerRuntimeStateOnline || isManagerOffline(manager) { + return DisplayRecovering + } + entry, hasEntry, err := getRuntimeMetadataEntry(codespace.UUID) + if err != nil || !hasEntry || !runtimeMetadataReadyForRunning(codespace, entry.Metadata) { + return DisplayMetadataRebuilding + } + for _, endpoint := range entry.Metadata.Endpoints { + if endpoint.EndpointID == workspaceEndpointID { + view.Workspace = &CreatorEndpointView{ + EndpointID: workspaceEndpointID, + Label: workspaceEndpointLabel, + OpenPath: codespaceDetailPath(codespace.UUID) + "/open", + } + continue + } + if !includeDetailData { + continue + } + portText, hasPortPrefix := strings.CutPrefix(endpoint.EndpointID, "port-") + var port uint64 + if hasPortPrefix { + port, _ = strconv.ParseUint(portText, 10, 16) + } + view.Endpoints = append(view.Endpoints, CreatorEndpointView{ + EndpointID: endpoint.EndpointID, + Label: endpoint.Label, + Port: uint16(port), + Public: endpoint.Public, + OpenPath: codespaceDetailPath(codespace.UUID) + "/open/" + endpoint.EndpointID, + }) + } + if view.Workspace == nil { + return DisplayMetadataRebuilding + } + if includeDetailData { + view.ResourceUsage = creatorResourceUsageView(entry.Metadata.ResourceUsage) + view.SSH = creatorSSHView(ctx, codespace, manager) + } + return DisplayRunning +} + +func creatorSSHView(ctx context.Context, codespace *codespace_model.Codespace, manager *codespace_model.Manager) *CreatorSSHView { + address := new(codespace_model.ManagerAddress) + has, err := db.GetEngine(ctx). + Where("manager_id = ? AND kind = ?", codespace.ManagerID, codespace_model.ManagerAddressSSH). + Get(address) + if err != nil || !has || address.Address == "" { + return nil + } + host, port, err := net.SplitHostPort(address.Address) + if err != nil { + return nil + } + command := "ssh cs-" + codespace.UUID + "@" + host + if port != "" && port != "22" { + command = "ssh -p " + port + " cs-" + codespace.UUID + "@" + host + } + return &CreatorSSHView{ + Command: command, + HostKeyAlgorithm: strings.TrimSpace(manager.GatewaySSHHostKeyAlgorithm), + HostKeyFingerprint: strings.TrimSpace(manager.GatewaySSHHostKeyFingerprintSHA256), + HostKeyUpdatedUnix: manager.GatewaySSHHostKeyUpdatedUnix, + } +} + +func creatorResourceUsageView(usage runtimeMetadataResourceUsage) *CreatorResourceUsageView { + return &CreatorResourceUsageView{ + CPU: CreatorResourceMetricView{ + Used: usage.CPU.UsedMillicores, + Limit: usage.CPU.LimitMillicores, + UsedDisplay: fmt.Sprintf("%dm", usage.CPU.UsedMillicores), + LimitDisplay: fmt.Sprintf("%dm", usage.CPU.LimitMillicores), + }, + Memory: CreatorResourceMetricView{ + Used: usage.Memory.UsedBytes, + Limit: usage.Memory.LimitBytes, + UsedDisplay: formatBytes(usage.Memory.UsedBytes), + LimitDisplay: formatBytes(usage.Memory.LimitBytes), + }, + Disk: CreatorResourceMetricView{ + Used: usage.Disk.UsedBytes, + Limit: usage.Disk.LimitBytes, + UsedDisplay: formatBytes(usage.Disk.UsedBytes), + LimitDisplay: formatBytes(usage.Disk.LimitBytes), + }, + ObservedUnix: usage.ObservedUnix, + } +} + +func formatBytes(value int64) string { + const unit = 1024 + if value < unit { + return fmt.Sprintf("%d B", value) + } + div, exp := int64(unit), 0 + for n := value / unit; n >= unit && exp < 4; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(value)/float64(div), "KMGTPE"[exp]) +} + +func applyCreatorActions(codespace *codespace_model.Codespace, view *CreatorCodespaceView, manager *codespace_model.Manager) { + view.CanDelete = codespace.Status != codespace_model.StatusDeleting + view.CanConfigureAutoStop = codespace.Status == codespace_model.StatusRunning || codespace.Status == codespace_model.StatusStopped + view.CanOpen = view.DisplayStatus == DisplayRunning && view.Workspace != nil && (!hasActiveOperation(codespace) || isQueuedIdleStop(codespace)) + if view.Workspace != nil { + view.Workspace.CanOpen = view.CanOpen + } + for i := range view.Endpoints { + view.Endpoints[i].CanOpen = view.DisplayStatus == DisplayRunning && (!hasActiveOperation(codespace) || (!view.Endpoints[i].Public && isQueuedIdleStop(codespace))) + } + view.CanContinue = codespace.Status == codespace_model.StatusRunning && isQueuedIdleStop(codespace) + view.CanStop = codespace.Status == codespace_model.StatusRunning && (!hasActiveOperation(codespace) || isQueuedIdleStop(codespace)) + view.CanResume = codespace.Status == codespace_model.StatusStopped && !hasActiveOperation(codespace) && view.DisplayStatus == DisplayStopped && manager != nil && manager.RuntimeState == codespace_model.ManagerRuntimeStateOnline && !isManagerOffline(manager) +} + +func statusSummary(displayStatus string) string { + switch displayStatus { + case DisplayQueued: + return "Waiting for a Codespace Manager" + case DisplayBooting: + return "Creating the runtime" + case DisplayRunning: + return "Ready" + case DisplayStopping: + return "Stopping" + case DisplayStopped: + return "Stopped" + case DisplayResuming: + return "Resuming" + case DisplayDeleting: + return "Deleting" + case DisplayFailed: + return "Failed" + case DisplayMetadataRebuilding: + return "Runtime metadata is not ready" + case DisplayRecovering: + return "Waiting for the Manager" + default: + return displayStatus + } +} + +func codespaceDetailPath(codespaceUUID string) string { + return "/-/codespaces/" + codespaceUUID +} diff --git a/services/codespace/view_test.go b/services/codespace/view_test.go new file mode 100644 index 0000000000000..c5c619ff4d8c2 --- /dev/null +++ b/services/codespace/view_test.go @@ -0,0 +1,334 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "testing" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListCreatorCodespacesShowsRunningWorkspace(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "17171717-1717-4717-8717-171717171717" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 17, + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 17, []map[string]any{ + {"endpoint_id": "port-3000", "label": "App", "public": false}, + }))) + + result, err := ListCreatorCodespaces(t.Context(), CreatorListOptions{UserID: 1, RepoID: 2}) + require.NoError(t, err) + require.Len(t, result.Rows, 1) + + row := result.Rows[0] + assert.Equal(t, codespaceUUID, row.UUID) + assert.Equal(t, DisplayRunning, row.DisplayStatus) + assert.Equal(t, DetailModeOverview, row.DetailMode) + assert.Equal(t, "default", row.EnvironmentTag) + assert.NotEmpty(t, row.CommitLink) + assert.Equal(t, refreshStableMilliseconds, row.RefreshAfterMillis) + require.NotNil(t, row.Workspace) + assert.Equal(t, "/-/codespaces/"+codespaceUUID+"/open", row.Workspace.OpenPath) + assert.Empty(t, row.Endpoints) + assert.Nil(t, row.ResourceUsage) + assert.Nil(t, row.SSH) + assert.True(t, row.CanOpen) + assert.True(t, row.CanStop) + assert.True(t, row.CanDelete) + assert.True(t, row.CanConfigureAutoStop) +} + +func TestListCreatorCodespacesPaginatesWithinRepositoryOwner(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + uuids := []string{ + "36363636-3636-4636-8636-363636363636", + "37373737-3737-4737-8737-373737373737", + "38383838-3838-4838-8838-383838383838", + } + for index, uuid := range uuids { + insertServiceCodespace(t, 0, &codespace_model.Codespace{UUID: uuid, Status: codespace_model.StatusStopped}) + _, err := db.GetEngine(t.Context()).ID(uuid).Cols("repo_id", "updated_unix").Update(&codespace_model.Codespace{ + RepoID: 3, + UpdatedUnix: int64(index + 2), + }) + require.NoError(t, err) + } + _, err := db.GetEngine(t.Context()).ID(uuids[2]).Cols("user_id").Update(&codespace_model.Codespace{UserID: 2}) + require.NoError(t, err) + + first, err := ListCreatorCodespaces(t.Context(), CreatorListOptions{UserID: 1, RepoOwnerID: 3, Page: 1, PageSize: 1}) + require.NoError(t, err) + assert.EqualValues(t, 2, first.Total) + require.Len(t, first.Rows, 1) + assert.Equal(t, uuids[1], first.Rows[0].UUID) + + second, err := ListCreatorCodespaces(t.Context(), CreatorListOptions{UserID: 1, RepoOwnerID: 3, Page: 2, PageSize: 1}) + require.NoError(t, err) + assert.EqualValues(t, 2, second.Total) + require.Len(t, second.Rows, 1) + assert.Equal(t, uuids[0], second.Rows[0].UUID) +} + +func TestListCreatorCodespacesAppliesLimit(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + for _, uuid := range []string{ + "31313131-3131-4131-8131-313131313131", + "32323232-3232-4232-8232-323232323232", + "33333333-3333-4333-8333-333333333333", + } { + insertServiceCodespace(t, 0, &codespace_model.Codespace{ + UUID: uuid, + Status: codespace_model.StatusStopped, + }) + } + + result, err := ListCreatorCodespaces(t.Context(), CreatorListOptions{UserID: 1, RepoID: 2, Limit: 2}) + require.NoError(t, err) + assert.Len(t, result.Rows, 2) +} + +func TestListCreatorCodespacesFiltersExactSource(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + pullUUID := "34343434-3434-4434-8434-343434343434" + insertServiceCodespace(t, 0, &codespace_model.Codespace{UUID: pullUUID, Status: codespace_model.StatusStopped}) + _, err := db.GetEngine(t.Context()).ID(pullUUID).Cols("ref_type", "ref_name").Update(&codespace_model.Codespace{ + RefType: "pull", + RefName: "refs/pull/3/head", + }) + require.NoError(t, err) + insertServiceCodespace(t, 0, &codespace_model.Codespace{UUID: "35353535-3535-4535-8535-353535353535", Status: codespace_model.StatusStopped}) + + result, err := ListCreatorCodespaces(t.Context(), CreatorListOptions{ + UserID: 1, + RepoID: 2, + RefType: "pull", + RefName: "refs/pull/3/head", + }) + require.NoError(t, err) + require.Len(t, result.Rows, 1) + assert.Equal(t, "#3", result.Rows[0].RefDisplayName) +} + +func TestListCreatorCodespacesFiltersCommitSHA(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + commitSHA := "65f1bf27bc3bf70f64657658635e66094edbcb4d" + matchingUUIDs := []string{ + "39393939-3939-4939-8939-393939393939", + "40404040-4040-4040-8040-404040404040", + } + refTypes := []string{"branch", "tag"} + refNames := []string{"main", "v1.0.0"} + for index, uuid := range matchingUUIDs { + insertServiceCodespace(t, 0, &codespace_model.Codespace{UUID: uuid, Status: codespace_model.StatusRunning}) + _, err := db.GetEngine(t.Context()).ID(uuid).Cols("ref_type", "ref_name", "commit_sha").Update(&codespace_model.Codespace{ + RefType: refTypes[index], + RefName: refNames[index], + CommitSHA: commitSHA, + }) + require.NoError(t, err) + } + nonMatchingUUID := "41414141-4141-4141-8141-414141414141" + insertServiceCodespace(t, 0, &codespace_model.Codespace{UUID: nonMatchingUUID, Status: codespace_model.StatusRunning}) + _, err := db.GetEngine(t.Context()).ID(nonMatchingUUID).Cols("commit_sha").Update(&codespace_model.Codespace{ + CommitSHA: "4a357436d925b5c974181ff12a994538ddc5a269", + }) + require.NoError(t, err) + + result, err := ListCreatorCodespaces(t.Context(), CreatorListOptions{ + UserID: 1, + RepoID: 2, + CommitSHA: commitSHA, + }) + require.NoError(t, err) + require.Len(t, result.Rows, 2) + assert.ElementsMatch(t, matchingUUIDs, []string{result.Rows[0].UUID, result.Rows[1].UUID}) +} + +func TestCreatorAutoStopViewUsesExactHumanUnits(t *testing.T) { + t.Cleanup(test.MockVariableValue(&setting.Codespace.AutoStopDefaultTimeout, 30*time.Minute)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.AutoStopMinTimeout, 5*time.Minute)) + t.Cleanup(test.MockVariableValue(&setting.Codespace.AutoStopMaxTimeout, 7*24*time.Hour)) + + view := creatorAutoStopView(&codespace_model.Codespace{ + AutoStopMode: codespace_model.AutoStopModeCustom, + AutoStopTimeoutSeconds: 90 * 60, + }) + + assert.Equal(t, CreatorDurationView{Value: 90, Unit: "minutes", TranslationKey: "tool.minutes"}, view.Timeout) + assert.Equal(t, CreatorDurationView{Value: 30, Unit: "minutes", TranslationKey: "tool.minutes"}, view.Default) + assert.Equal(t, CreatorDurationView{Value: 5, Unit: "minutes", TranslationKey: "tool.minutes"}, view.Minimum) + assert.Equal(t, CreatorDurationView{Value: 7, Unit: "days", TranslationKey: "tool.days"}, view.Maximum) + assert.True(t, view.EffectiveEnabled) + assert.Equal(t, view.Timeout, view.EffectiveTimeout) + assert.False(t, view.CustomTimeoutOutOfRange) + + view = creatorAutoStopView(&codespace_model.Codespace{ + AutoStopMode: codespace_model.AutoStopModeCustom, + AutoStopTimeoutSeconds: 301, + }) + assert.Equal(t, CreatorDurationView{Value: 301, Unit: "seconds", TranslationKey: "tool.seconds"}, view.Timeout) + assert.False(t, view.CustomTimeoutOutOfRange) + + view = creatorAutoStopView(&codespace_model.Codespace{AutoStopMode: codespace_model.AutoStopModeDefault}) + assert.Equal(t, view.Default, view.Timeout) + assert.True(t, view.EffectiveEnabled) + + view = creatorAutoStopView(&codespace_model.Codespace{AutoStopMode: codespace_model.AutoStopModeNever}) + assert.Equal(t, view.Default, view.Timeout) + assert.False(t, view.EffectiveEnabled) + + view = creatorAutoStopView(&codespace_model.Codespace{ + AutoStopMode: codespace_model.AutoStopModeCustom, + AutoStopTimeoutSeconds: 4 * 60, + }) + assert.True(t, view.CustomTimeoutOutOfRange) +} + +func TestGetCreatorCodespaceKeepsQueuedIdleStopInteractive(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + _, err := db.GetEngine(t.Context()).ID(manager.ID).Cols("gateway_ssh_host_key_algorithm", "gateway_ssh_host_key_fingerprint_sha256", "gateway_ssh_host_key_updated_unix").Update(&codespace_model.Manager{ + GatewaySSHHostKeyAlgorithm: "ssh-ed25519", + GatewaySSHHostKeyFingerprintSHA256: "SHA256:view", + GatewaySSHHostKeyUpdatedUnix: 123, + }) + require.NoError(t, err) + insertSettingsManagerAddress(t, manager.ID, codespace_model.ManagerAddressSSH, "ssh.example.com:2222") + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "18181818-1818-4818-8818-181818181818" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusRunning, + OperationRVersion: 18, + OperationType: codespace_model.OperationStop, + OperationStatus: codespace_model.OperationStatusQueued, + OperationTrigger: codespace_model.OperationTriggerIdle, + OperationCreatedUnix: time.Now().Unix(), + }) + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 18, []map[string]any{ + {"endpoint_id": "private-app", "label": "Private app", "public": false}, + {"endpoint_id": "public-app", "label": "Public app", "public": true}, + }))) + + view, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceUUID: codespaceUUID}) + require.NoError(t, err) + + assert.Equal(t, DisplayRunning, view.DisplayStatus) + assert.Equal(t, DetailModeOverview, view.DetailMode) + assert.Equal(t, refreshStableMilliseconds, view.RefreshAfterMillis) + assert.True(t, view.CanOpen) + assert.True(t, view.CanContinue) + assert.True(t, view.CanStop) + require.Len(t, view.Endpoints, 2) + assert.True(t, view.Endpoints[0].CanOpen) + assert.False(t, view.Endpoints[1].CanOpen) + require.NotNil(t, view.SSH) + assert.Equal(t, "ssh -p 2222 cs-"+codespaceUUID+"@ssh.example.com", view.SSH.Command) + assert.Equal(t, "ssh-ed25519", view.SSH.HostKeyAlgorithm) + assert.Equal(t, "SHA256:view", view.SSH.HostKeyFingerprint) + assert.EqualValues(t, 123, view.SSH.HostKeyUpdatedUnix) +} + +func TestGetCreatorCodespaceShowsTransitionsAndPermissions(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + codespaceUUID := "19191919-1919-4919-8919-191919191919" + insertServiceCodespace(t, 0, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusQueued, + }) + + view, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceUUID: codespaceUUID}) + require.NoError(t, err) + assert.Equal(t, DisplayQueued, view.DisplayStatus) + assert.Equal(t, DetailModeLogs, view.DetailMode) + assert.Equal(t, refreshTransitionMilliseconds, view.RefreshAfterMillis) + assert.False(t, view.CanOpen) + assert.True(t, view.CanDelete) + + _, err = GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 2, CodespaceUUID: codespaceUUID}) + require.ErrorIs(t, err, ErrViewPermissionDenied) +} + +func TestGetCreatorCodespaceShowsCurrentBootStage(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "29292929-2929-4929-8929-292929292929" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: codespaceUUID, + Status: codespace_model.StatusCreating, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + OperationRVersion: 29, + }) + entry := serviceRuntimeMetadataEntry(t, 29, nil) + entry.Metadata.Boot.Stage = bootStagePrepareWorkspace + require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, entry)) + + view, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceUUID: codespaceUUID}) + require.NoError(t, err) + assert.Equal(t, DisplayBooting, view.DisplayStatus) + assert.Equal(t, DetailModeLogs, view.DetailMode) + assert.Equal(t, "codespace.boot_stage.prepare_workspace", view.BootStageKey) +} + +func TestStoppedCreatorCodespaceResumeRequiresOnlineManager(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + offlineUUID := "20202020-2020-4020-8020-202020202020" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: offlineUUID, + Status: codespace_model.StatusStopped, + }) + + offlineView, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceUUID: offlineUUID}) + require.NoError(t, err) + assert.Equal(t, DisplayStopped, offlineView.DisplayStatus) + assert.False(t, offlineView.CanResume) + + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + onlineUUID := "21212121-2121-4121-8121-212121212121" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: onlineUUID, + Status: codespace_model.StatusStopped, + }) + + onlineView, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceUUID: onlineUUID}) + require.NoError(t, err) + assert.Equal(t, DisplayStopped, onlineView.DisplayStatus) + assert.True(t, onlineView.CanResume) + + _, err = db.GetEngine(t.Context()).ID(manager.ID).Cols("runtime_state").Update(&codespace_model.Manager{ + RuntimeState: codespace_model.ManagerRuntimeStateRecovering, + }) + require.NoError(t, err) + recoveringView, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceUUID: onlineUUID}) + require.NoError(t, err) + assert.False(t, recoveringView.CanResume) +} diff --git a/services/context/api.go b/services/context/api.go index 50772bcea6384..50ed99ddc8a84 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -13,6 +13,8 @@ import ( "strconv" "strings" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/perm" repo_model "gitea.dev/models/repo" "gitea.dev/models/unit" user_model "gitea.dev/models/user" @@ -48,12 +50,56 @@ type APIContext struct { } // TokenCanAccessRepo reports whether the current API token is allowed to access the repository. -// A public-only token cannot reach a private repo or a repo owned by a non-public (limited or -// private) owner; any other token is unrestricted by this check. +// Codespace Tokens use their binding for authenticated repo operations, while public read +// requests still follow Gitea's public visibility rules. func (ctx *APIContext) TokenCanAccessRepo(repo *repo_model.Repository) bool { + if snapshot, ok := codespaceTokenSnapshotFromData(ctx.GetData()); ok { + return codespaceTokenCanAccessRepo(repo, snapshot) + } return !ctx.PublicOnly || !publicOnlyTokenDeniedRepo(ctx, repo) } +// CodespaceTokenRepoID returns the repository bound to the current Codespace Token. +func (ctx *APIContext) CodespaceTokenRepoID() (int64, bool) { + snapshot, ok := codespaceTokenSnapshotFromData(ctx.GetData()) + if !ok { + return 0, false + } + return snapshot.CodespaceTokenRepoID(), true +} + +// CodespaceTokenAllowsRepository reports whether the current Codespace Token grants a repository unit permission. +func (ctx *APIContext) CodespaceTokenAllowsRepository(unitType unit.Type, mode perm.AccessMode) bool { + snapshot, ok := codespaceTokenSnapshotFromData(ctx.GetData()) + return !ok || ctx.Repo != nil && ctx.Repo.Repository != nil && snapshot.CodespaceTokenAllowsRepository(ctx.Repo.Repository.ID, unitType, mode) +} + +// CodespaceTokenAllowsRepositoryID reports whether the current Codespace Token grants a unit permission for a repository ID. +func (ctx *APIContext) CodespaceTokenAllowsRepositoryID(repoID int64, unitType unit.Type, mode perm.AccessMode) bool { + snapshot, ok := codespaceTokenSnapshotFromData(ctx.GetData()) + return !ok || snapshot.CodespaceTokenAllowsRepository(repoID, unitType, mode) +} + +// UseAnonymousForPublicCodespaceRead drops Codespace identity for an ungranted public repository read. +func (ctx *APIContext) UseAnonymousForPublicCodespaceRead(repo *repo_model.Repository) bool { + snapshot, ok := codespaceTokenSnapshotFromData(ctx.GetData()) + if !ok || repo == nil || snapshot.CodespaceTokenAllowsAnyRepository(repo.ID) || ctx.Req == nil { + return false + } + if ctx.Req.Method != http.MethodGet && ctx.Req.Method != http.MethodHead { + return false + } + if publicOnlyTokenDeniedRepo(ctx, repo) { + return false + } + delete(ctx.GetData(), codespace_model.GiteaTokenAuthDataKey) + delete(ctx.GetData(), "ApiTokenScope") + ctx.GetData()["IsApiToken"] = false + ctx.Doer = nil + ctx.IsSigned = false + return true +} + func init() { web.RegisterResponseStatusProvider[*APIContext](func(req *http.Request) web_types.ResponseStatusProvider { return req.Context().Value(apiContextKey).(*APIContext) diff --git a/services/context/api_test.go b/services/context/api_test.go index 8a628d4db2a64..90d38078d1ff6 100644 --- a/services/context/api_test.go +++ b/services/context/api_test.go @@ -4,10 +4,17 @@ package context import ( + "net/http" "net/url" "strconv" "testing" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/perm" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unit" + user_model "gitea.dev/models/user" + "gitea.dev/modules/reqctx" "gitea.dev/modules/setting" "github.com/stretchr/testify/assert" @@ -48,3 +55,51 @@ func TestGenAPILinks(t *testing.T) { assert.Equal(t, links, response) } } + +func TestAPIContextTokenCanAccessRepoForCodespaceToken(t *testing.T) { + ctx := &APIContext{Base: &Base{RequestContext: reqctx.NewRequestContextForTest(t.Context())}} + ctx.Req, _ = http.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/repos/user5/repo4", nil) + ctx.GetData()[codespace_model.GiteaTokenAuthDataKey] = testCodespaceTokenSnapshot{repoID: 2} + + assert.True(t, ctx.TokenCanAccessRepo(&repo_model.Repository{ID: 2})) + assert.False(t, ctx.TokenCanAccessRepo(&repo_model.Repository{ID: 3, IsPrivate: false})) + assert.False(t, ctx.TokenCanAccessRepo(&repo_model.Repository{ID: 4, IsPrivate: true})) + assert.False(t, ctx.TokenCanAccessRepo(nil)) + + ctx.GetData()[codespace_model.GiteaTokenAuthDataKey] = testCodespaceTokenSnapshot{repoID: 0} + assert.False(t, ctx.TokenCanAccessRepo(&repo_model.Repository{ID: 2, IsPrivate: false})) + + ctx.Req, _ = http.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/repos/user5/repo4", nil) + assert.False(t, ctx.TokenCanAccessRepo(&repo_model.Repository{ID: 2, IsPrivate: false})) +} + +func TestUseAnonymousForPublicCodespaceRead(t *testing.T) { + ctx := &APIContext{Base: &Base{RequestContext: reqctx.NewRequestContextForTest(t.Context())}} + ctx.Req, _ = http.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/repos/public/repo", nil) + ctx.Doer = &user_model.User{ID: 2} + ctx.IsSigned = true + ctx.GetData()[codespace_model.GiteaTokenAuthDataKey] = testCodespaceTokenSnapshot{repoID: 1} + ctx.GetData()["IsApiToken"] = true + + assert.True(t, ctx.UseAnonymousForPublicCodespaceRead(&repo_model.Repository{ID: 2, Owner: &user_model.User{}})) + assert.Nil(t, ctx.Doer) + assert.False(t, ctx.IsSigned) + _, hasSnapshot := ctx.CodespaceTokenRepoID() + assert.False(t, hasSnapshot) +} + +type testCodespaceTokenSnapshot struct { + repoID int64 +} + +func (s testCodespaceTokenSnapshot) CodespaceTokenRepoID() int64 { + return s.repoID +} + +func (s testCodespaceTokenSnapshot) CodespaceTokenAllowsAnyRepository(repoID int64) bool { + return repoID == s.repoID +} + +func (s testCodespaceTokenSnapshot) CodespaceTokenAllowsRepository(repoID int64, _ unit.Type, _ perm.AccessMode) bool { + return repoID == s.repoID +} diff --git a/services/context/context.go b/services/context/context.go index c1e438975f66c..6f1fa494f783c 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -206,6 +206,7 @@ func Contexter() func(next http.Handler) http.Handler { ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations ctx.Data["DisableStars"] = setting.Repository.DisableStars ctx.Data["EnableActions"] = setting.Actions.Enabled && !unit.TypeActions.UnitGlobalDisabled() + ctx.Data["EnableCodespaces"] = setting.Codespace.Enabled ctx.Data["AllLangs"] = translation.AllLangs() diff --git a/services/context/permission.go b/services/context/permission.go index f0c93081c6a82..3492a672620fb 100644 --- a/services/context/permission.go +++ b/services/context/permission.go @@ -9,8 +9,11 @@ import ( "slices" auth_model "gitea.dev/models/auth" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/perm" repo_model "gitea.dev/models/repo" "gitea.dev/models/unit" + "gitea.dev/modules/reqctx" ) // isOwnerHidden reports whether repo's owner is not publicly visible (a limited or private owner), so @@ -32,6 +35,27 @@ func publicOnlyTokenDeniedRepo(ctx context.Context, repo *repo_model.Repository) return repo.IsPrivate || isOwnerHidden(ctx, repo) } +func codespaceTokenCanAccessRepo(repo *repo_model.Repository, snapshot codespaceTokenRepoSnapshot) bool { + if repo == nil { + return false + } + return snapshot.CodespaceTokenAllowsAnyRepository(repo.ID) +} + +type codespaceTokenRepoSnapshot interface { + CodespaceTokenRepoID() int64 + CodespaceTokenAllowsAnyRepository(repoID int64) bool + CodespaceTokenAllowsRepository(repoID int64, unitType unit.Type, mode perm.AccessMode) bool +} + +func codespaceTokenSnapshotFromData(data reqctx.ContextData) (codespaceTokenRepoSnapshot, bool) { + snapshot, ok := data[codespace_model.GiteaTokenAuthDataKey].(codespaceTokenRepoSnapshot) + if !ok { + return nil, false + } + return snapshot, true +} + // TokenIsPublicOnly reports whether the request is authenticated by a public-only API token. A // non-token request, or a token with no recorded scope, is not public-only. func TokenIsPublicOnly(ctx *Context) bool { @@ -125,6 +149,29 @@ func RequireUnitReader(unitTypes ...unit.Type) func(ctx *Context) { } // CheckRepoScopedToken checks whether the authenticated API token has repo scope. -func CheckRepoScopedToken(ctx *Context, repo *repo_model.Repository, level auth_model.AccessTokenScopeLevel) { +func CheckRepoScopedToken(ctx *Context, repo *repo_model.Repository, unitType unit.Type, level auth_model.AccessTokenScopeLevel) { + if snapshot, ok := codespaceTokenSnapshotFromData(ctx.Data); ok { + mode := perm.AccessModeRead + if level == auth_model.Write { + mode = perm.AccessModeWrite + } + allowed := repo != nil && snapshot.CodespaceTokenAllowsRepository(repo.ID, unitType, mode) + if !allowed && mode == perm.AccessModeRead && repo != nil { + allowed = !publicOnlyTokenDeniedRepo(ctx, repo) + } + if !allowed { + ctx.HTTPError(http.StatusForbidden) + return + } + } CheckTokenScopes(ctx, repo, auth_model.GetRequiredScopes(level, auth_model.AccessTokenScopeCategoryRepository)...) } + +// CodespaceTokenRepoID returns the repository bound to the current Codespace Token. +func CodespaceTokenRepoID(ctx *Context) (int64, bool) { + snapshot, ok := codespaceTokenSnapshotFromData(ctx.Data) + if !ok { + return 0, false + } + return snapshot.CodespaceTokenRepoID(), true +} diff --git a/services/context/permission_test.go b/services/context/permission_test.go new file mode 100644 index 0000000000000..57cebb8eeef88 --- /dev/null +++ b/services/context/permission_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context_test + +import ( + "net/http" + "testing" + + auth_model "gitea.dev/models/auth" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/perm" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unit" + user_model "gitea.dev/models/user" + gitea_context "gitea.dev/services/context" + "gitea.dev/services/contexttest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckRepoScopedTokenForCodespaceToken(t *testing.T) { + scope, err := auth_model.AccessTokenScope("write:repository").Normalize() + require.NoError(t, err) + + t.Run("matched repo", func(t *testing.T) { + ctx, _ := contexttest.MockContext(t, "GET /user2/repo1.git/info/refs") + ctx.Data["IsApiToken"] = true + ctx.Data["ApiTokenScope"] = scope + ctx.Data[codespace_model.GiteaTokenAuthDataKey] = testCodespaceTokenSnapshot{repoID: 1} + + gitea_context.CheckRepoScopedToken(ctx, &repo_model.Repository{ID: 1}, unit.TypeCode, auth_model.Read) + + assert.Equal(t, 0, ctx.Resp.WrittenStatus()) + }) + + t.Run("private other repo read requires grant", func(t *testing.T) { + ctx, _ := contexttest.MockContext(t, "GET /user2/repo1.git/info/refs") + ctx.Data["IsApiToken"] = true + ctx.Data["ApiTokenScope"] = scope + ctx.Data[codespace_model.GiteaTokenAuthDataKey] = testCodespaceTokenSnapshot{repoID: 1} + + gitea_context.CheckRepoScopedToken(ctx, &repo_model.Repository{ID: 2, IsPrivate: true}, unit.TypeCode, auth_model.Read) + + assert.Equal(t, http.StatusForbidden, ctx.Resp.WrittenStatus()) + }) + + t.Run("granted other repo read", func(t *testing.T) { + ctx, _ := contexttest.MockContext(t, "GET /user2/repo1.git/info/refs") + ctx.Data["IsApiToken"] = true + ctx.Data["ApiTokenScope"] = scope + ctx.Data[codespace_model.GiteaTokenAuthDataKey] = testCodespaceTokenSnapshot{repoID: 1, grants: map[int64]map[unit.Type]perm.AccessMode{2: {unit.TypeCode: perm.AccessModeRead}}} + + gitea_context.CheckRepoScopedToken(ctx, &repo_model.Repository{ID: 2}, unit.TypeCode, auth_model.Read) + + assert.Equal(t, 0, ctx.Resp.WrittenStatus()) + }) + + t.Run("public other repo read", func(t *testing.T) { + ctx, _ := contexttest.MockContext(t, "GET /user2/repo1.git/info/refs") + ctx.Data["IsApiToken"] = true + ctx.Data["ApiTokenScope"] = scope + ctx.Data[codespace_model.GiteaTokenAuthDataKey] = testCodespaceTokenSnapshot{repoID: 1} + + gitea_context.CheckRepoScopedToken(ctx, &repo_model.Repository{ID: 2, Owner: &user_model.User{}}, unit.TypeCode, auth_model.Read) + + assert.Equal(t, 0, ctx.Resp.WrittenStatus()) + }) + + t.Run("public other repo write", func(t *testing.T) { + ctx, _ := contexttest.MockContext(t, "POST /user2/repo1.git/git-receive-pack") + ctx.Data["IsApiToken"] = true + ctx.Data["ApiTokenScope"] = scope + ctx.Data[codespace_model.GiteaTokenAuthDataKey] = testCodespaceTokenSnapshot{repoID: 1} + + gitea_context.CheckRepoScopedToken(ctx, &repo_model.Repository{ID: 2}, unit.TypeCode, auth_model.Write) + + assert.Equal(t, http.StatusForbidden, ctx.Resp.WrittenStatus()) + }) + + t.Run("missing source repository", func(t *testing.T) { + ctx, _ := contexttest.MockContext(t, "GET /user2/repo1.git/info/refs") + ctx.Data["IsApiToken"] = true + ctx.Data["ApiTokenScope"] = scope + ctx.Data[codespace_model.GiteaTokenAuthDataKey] = testCodespaceTokenSnapshot{repoID: 0} + + gitea_context.CheckRepoScopedToken(ctx, &repo_model.Repository{ID: 1, IsPrivate: true}, unit.TypeCode, auth_model.Read) + + assert.Equal(t, http.StatusForbidden, ctx.Resp.WrittenStatus()) + }) +} + +type testCodespaceTokenSnapshot struct { + repoID int64 + grants map[int64]map[unit.Type]perm.AccessMode +} + +func (s testCodespaceTokenSnapshot) CodespaceTokenAllowsAnyRepository(repoID int64) bool { + return repoID == s.repoID || len(s.grants[repoID]) > 0 +} + +func (s testCodespaceTokenSnapshot) CodespaceTokenAllowsRepository(repoID int64, unitType unit.Type, mode perm.AccessMode) bool { + return repoID == s.repoID || s.grants[repoID][unitType] >= mode +} + +func (s testCodespaceTokenSnapshot) CodespaceTokenRepoID() int64 { + return s.repoID +} diff --git a/services/cron/tasks.go b/services/cron/tasks.go index 74ab23a3d135b..a405ed1091cbb 100644 --- a/services/cron/tasks.go +++ b/services/cron/tasks.go @@ -181,7 +181,6 @@ func RegisterTask(name string, config Config, fun func(context.Context, *user_mo log.Error("Unable to register cron task with name: %s Error: %v", name, err) return err } - task := &Task{ Name: name, config: config, diff --git a/services/cron/tasks_basic.go b/services/cron/tasks_basic.go index 34183b2271b87..f11acafc49835 100644 --- a/services/cron/tasks_basic.go +++ b/services/cron/tasks_basic.go @@ -14,6 +14,7 @@ import ( "gitea.dev/modules/git/gitcmd" "gitea.dev/modules/setting" "gitea.dev/services/auth" + codespace_service "gitea.dev/services/codespace" "gitea.dev/services/migrations" mirror_service "gitea.dev/services/mirror" packages_cleanup_service "gitea.dev/services/packages/cleanup" @@ -21,6 +22,12 @@ import ( archiver_service "gitea.dev/services/repository/archiver" ) +// ReconcileCodespacesConfig contains settings for the Codespace maintenance task. +type ReconcileCodespacesConfig struct { + BaseConfig + OlderThan time.Duration +} + func registerUpdateMirrorTask() { type UpdateMirrorTaskConfig struct { BaseConfig @@ -156,6 +163,23 @@ func registerCleanupPackages() { }) } +func registerReconcileCodespaces() { + RegisterTaskFatal("reconcile_codespaces", &ReconcileCodespacesConfig{ + BaseConfig: BaseConfig{ + Enabled: true, + RunAtStart: true, + Schedule: "@every 1m", + }, + OlderThan: 8760 * time.Hour, + }, func(ctx context.Context, _ *user_model.User, config Config) error { + realConfig := config.(*ReconcileCodespacesConfig) + _, err := codespace_service.ReconcileCodespaces(ctx, codespace_service.ReconcileCodespacesOptions{ + FailedOlderThan: realConfig.OlderThan, + }) + return err + }) +} + func registerSyncRepoLicenses() { RegisterTaskFatal("sync_repo_licenses", &BaseConfig{ Enabled: false, @@ -182,5 +206,6 @@ func initBasicTasks() { if setting.Packages.Enabled { registerCleanupPackages() } + registerReconcileCodespaces() registerSyncRepoLicenses() } diff --git a/services/cron/tasks_extended_test.go b/services/cron/tasks_extended_test.go index 7df8eb056b97b..c2be5bee371e2 100644 --- a/services/cron/tasks_extended_test.go +++ b/services/cron/tasks_extended_test.go @@ -49,3 +49,31 @@ PROPORTION_TO_CHECK_PER_REPO = 0.1 assert.Equal(t, int64(10), config.NumberToCheckPerRepo) assert.InDelta(t, 0.1, config.ProportionToCheckPerRepo, 0.001) } + +func Test_ReconcileCodespacesConfig(t *testing.T) { + cfg, err := setting.NewConfigProviderFromData(` +[cron.reconcile_codespaces] +ENABLED = true +RUN_AT_START = true +SCHEDULE = "@every 2m" +OLDER_THAN = "24h" +`) + assert.NoError(t, err) + defer test.MockVariableValue(&setting.CfgProvider, cfg)() + + config := &ReconcileCodespacesConfig{ + BaseConfig: BaseConfig{ + Enabled: true, + RunAtStart: true, + Schedule: "@every 1m", + }, + OlderThan: 8760 * time.Hour, + } + + _, err = setting.GetCronSettings("reconcile_codespaces", config) + assert.NoError(t, err) + assert.True(t, config.Enabled) + assert.True(t, config.RunAtStart) + assert.Equal(t, "@every 2m", config.Schedule) + assert.Equal(t, 24*time.Hour, config.OlderThan) +} diff --git a/services/lfs/locks.go b/services/lfs/locks.go index fe3f4bdbec981..18da26914605c 100644 --- a/services/lfs/locks.go +++ b/services/lfs/locks.go @@ -11,6 +11,7 @@ import ( auth_model "gitea.dev/models/auth" git_model "gitea.dev/models/git" repo_model "gitea.dev/models/repo" + "gitea.dev/models/unit" "gitea.dev/modules/json" lfs_module "gitea.dev/modules/lfs" "gitea.dev/modules/log" @@ -59,7 +60,7 @@ func GetListLockHandler(ctx *context.Context) { } repository.MustOwner(ctx) - context.CheckRepoScopedToken(ctx, repository, auth_model.Read) + context.CheckRepoScopedToken(ctx, repository, unit.TypeCode, auth_model.Read) if ctx.Written() { return } @@ -148,7 +149,7 @@ func PostLockHandler(ctx *context.Context) { } repository.MustOwner(ctx) - context.CheckRepoScopedToken(ctx, repository, auth_model.Write) + context.CheckRepoScopedToken(ctx, repository, unit.TypeCode, auth_model.Write) if ctx.Written() { return } @@ -213,7 +214,7 @@ func VerifyLockHandler(ctx *context.Context) { } repository.MustOwner(ctx) - context.CheckRepoScopedToken(ctx, repository, auth_model.Read) + context.CheckRepoScopedToken(ctx, repository, unit.TypeCode, auth_model.Read) if ctx.Written() { return } @@ -281,7 +282,7 @@ func UnLockHandler(ctx *context.Context) { } repository.MustOwner(ctx) - context.CheckRepoScopedToken(ctx, repository, auth_model.Write) + context.CheckRepoScopedToken(ctx, repository, unit.TypeCode, auth_model.Write) if ctx.Written() { return } diff --git a/services/lfs/server.go b/services/lfs/server.go index 933874013693e..f4b4821a35d0c 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -470,9 +470,9 @@ func getAuthenticatedRepository(ctx *context.Context, rc *requestContext, requir } if requireWrite { - context.CheckRepoScopedToken(ctx, repository, auth_model.Write) + context.CheckRepoScopedToken(ctx, repository, unit.TypeCode, auth_model.Write) } else { - context.CheckRepoScopedToken(ctx, repository, auth_model.Read) + context.CheckRepoScopedToken(ctx, repository, unit.TypeCode, auth_model.Read) } if ctx.Written() { diff --git a/services/org/org.go b/services/org/org.go index 953e869426c0c..a3577fca61287 100644 --- a/services/org/org.go +++ b/services/org/org.go @@ -54,29 +54,22 @@ func deleteOrganization(ctx context.Context, org *org_model.Organization) error // DeleteOrganization completely and permanently deletes everything of organization. func DeleteOrganization(ctx context.Context, org *org_model.Organization, purge bool) error { - if err := db.WithTx(ctx, func(ctx context.Context) error { - if purge { - err := repo_service.DeleteOwnerRepositoriesDirectly(ctx, org.AsUser()) - if err != nil { - return err - } - } + if org.Type != user_model.UserTypeOrganization { + return fmt.Errorf("%s is a user not an organization", org.Name) + } - // Check ownership of repository. - count, err := repo_model.CountRepositories(ctx, repo_model.CountRepositoryOptions{OwnerID: org.ID}) - if err != nil { - return fmt.Errorf("GetRepositoryCount: %w", err) - } else if count > 0 { - return repo_model.ErrUserOwnRepos{UID: org.ID} + if purge { + if err := repo_service.DeleteOwnerRepositoriesDirectly(ctx, org.AsUser()); err != nil { + return err } + } else if err := checkDeleteOrganizationPreconditions(ctx, org); err != nil { + return err + } - // Check ownership of packages. - if ownsPackages, err := packages_model.HasOwnerPackages(ctx, org.ID); err != nil { - return fmt.Errorf("HasOwnerPackages: %w", err) - } else if ownsPackages { - return packages_model.ErrUserOwnPackages{UID: org.ID} + if err := db.WithTx(ctx, func(ctx context.Context) error { + if err := checkDeleteOrganizationPreconditions(ctx, org); err != nil { + return err } - if err := deleteOrganization(ctx, org); err != nil { return fmt.Errorf("DeleteOrganization: %w", err) } @@ -104,6 +97,22 @@ func DeleteOrganization(ctx context.Context, org *org_model.Organization, purge return nil } +func checkDeleteOrganizationPreconditions(ctx context.Context, org *org_model.Organization) error { + count, err := repo_model.CountRepositories(ctx, repo_model.CountRepositoryOptions{OwnerID: org.ID}) + if err != nil { + return fmt.Errorf("GetRepositoryCount: %w", err) + } else if count > 0 { + return repo_model.ErrUserOwnRepos{UID: org.ID} + } + + if ownsPackages, err := packages_model.HasOwnerPackages(ctx, org.ID); err != nil { + return fmt.Errorf("HasOwnerPackages: %w", err) + } else if ownsPackages { + return packages_model.ErrUserOwnPackages{UID: org.ID} + } + return nil +} + func updateRepoForVisibilityChanged(ctx context.Context, repo *repo_model.Repository, makePrivate bool) error { if err := repo.LoadOwner(ctx); err != nil { return fmt.Errorf("LoadOwner: %w", err) diff --git a/services/repository/codespace_test.go b/services/repository/codespace_test.go new file mode 100644 index 0000000000000..81f4219345949 --- /dev/null +++ b/services/repository/codespace_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository + +import ( + "testing" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "gitea.dev/models/perm" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unit" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeleteRepositoryDirectlyUnbindsCodespaces(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo, err := CreateRepositoryDirectly(t.Context(), user, user, CreateRepoOptions{ + Name: "codespace-source", + }, true) + require.NoError(t, err) + require.NotNil(t, repo) + targetRepo, err := CreateRepositoryDirectly(t.Context(), user, user, CreateRepoOptions{ + Name: "codespace-target", + }, true) + require.NoError(t, err) + require.NotNil(t, targetRepo) + authorization := &codespace_model.PermissionAuthorization{ + UserID: user.ID, SourceRepoID: repo.ID, RequestHash: "repository-delete", + CreatedUnix: 100, UpdatedUnix: 100, + } + require.NoError(t, db.Insert(t.Context(), authorization)) + rule := &codespace_model.PermissionRepository{ + AuthorizationID: authorization.ID, TargetRepoID: targetRepo.ID, UnitType: unit.TypeCode, + RequestedMode: perm.AccessModeRead, GrantedMode: perm.AccessModeRead, + } + require.NoError(t, db.Insert(t.Context(), rule)) + secret := &codespace_model.UserSecret{UserID: user.ID, Name: "DATABASE_PASSWORD", DataEncrypted: "encrypted", DataSize: 8} + require.NoError(t, db.Insert(t.Context(), secret)) + targetSecretRepository := &codespace_model.UserSecretRepository{SecretID: secret.ID, RepoID: targetRepo.ID} + require.NoError(t, db.Insert(t.Context(), targetSecretRepository)) + sourceSecretRepository := &codespace_model.UserSecretRepository{SecretID: secret.ID, RepoID: repo.ID} + require.NoError(t, db.Insert(t.Context(), sourceSecretRepository)) + + codespaceUUID := codespace_model.NewUUID() + require.NoError(t, db.Insert(t.Context(), &codespace_model.Codespace{ + UUID: codespaceUUID, + UserID: user.ID, + RepoID: repo.ID, + PermissionAuthorizationID: authorization.ID, + RefType: "branch", + RefName: "main", + EnvironmentTag: "default", + CommitSHA: "0123456789012345678901234567890123456789", + Status: codespace_model.StatusRunning, + OperationRVersion: 2, + CreatedUnix: 100, + UpdatedUnix: 200, + LastActiveUnix: 150, + AutoStopMode: codespace_model.AutoStopModeDefault, + InteractionGeneration: 3, + RuntimeGeneration: 4, + OperationCreatedUnix: 0, + OperationStartedUnix: 0, + OperationDeadlineUnix: 0, + AutoStopTimeoutSeconds: 0, + })) + require.NoError(t, db.Insert(t.Context(), &codespace_model.GiteaToken{ + CodespaceUUID: codespaceUUID, + TokenHash: "codespace-token-hash", + TokenSalt: "salt", + TokenLastEight: "87654321", + TokenEncrypted: "encrypted", + })) + + require.NoError(t, DeleteRepositoryDirectly(t.Context(), targetRepo.ID)) + unittest.AssertNotExistsBean(t, &codespace_model.PermissionRepository{ID: rule.ID}) + unittest.AssertExistsAndLoadBean(t, &codespace_model.PermissionAuthorization{ID: authorization.ID}) + unittest.AssertNotExistsBean(t, &codespace_model.UserSecretRepository{ID: targetSecretRepository.ID}) + unittest.AssertExistsAndLoadBean(t, &codespace_model.UserSecretRepository{ID: sourceSecretRepository.ID}) + + require.NoError(t, DeleteRepositoryDirectly(t.Context(), repo.ID)) + + unittest.AssertNotExistsBean(t, &repo_model.Repository{ID: repo.ID}) + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(t.Context()).ID(codespaceUUID).Get(codespace) + require.NoError(t, err) + require.True(t, has) + assert.Zero(t, codespace.RepoID) + assert.Zero(t, codespace.PermissionAuthorizationID) + assert.Equal(t, codespace_model.StatusRunning, codespace.Status) + assert.EqualValues(t, 200, codespace.UpdatedUnix) + assert.EqualValues(t, 3, codespace.InteractionGeneration) + assert.EqualValues(t, 4, codespace.RuntimeGeneration) + token := new(codespace_model.GiteaToken) + has, err = db.GetEngine(t.Context()).ID(codespaceUUID).Get(token) + require.NoError(t, err) + require.True(t, has) + unittest.AssertNotExistsBean(t, &codespace_model.PermissionAuthorization{ID: authorization.ID}) + unittest.AssertNotExistsBean(t, &codespace_model.UserSecretRepository{ID: sourceSecretRepository.ID}) + unittest.AssertExistsAndLoadBean(t, &codespace_model.UserSecret{ID: secret.ID}) +} diff --git a/services/repository/delete.go b/services/repository/delete.go index 38368fa2f4d1c..9bd70cad3ba2c 100644 --- a/services/repository/delete.go +++ b/services/repository/delete.go @@ -10,6 +10,7 @@ import ( actions_model "gitea.dev/models/actions" activities_model "gitea.dev/models/activities" admin_model "gitea.dev/models/admin" + codespace_model "gitea.dev/models/codespace" "gitea.dev/models/db" git_model "gitea.dev/models/git" issues_model "gitea.dev/models/issues" @@ -24,6 +25,7 @@ import ( "gitea.dev/models/webhook" actions_module "gitea.dev/modules/actions" "gitea.dev/modules/git" + "gitea.dev/modules/globallock" "gitea.dev/modules/graceful" "gitea.dev/modules/lfs" "gitea.dev/modules/log" @@ -51,6 +53,12 @@ func deleteDBRepository(ctx context.Context, repoID int64) error { // DeleteRepository deletes a repository for a user or organization. // make sure if you call this func to close open sessions (sqlite will otherwise get a deadlock) func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams ...bool) error { + return globallock.LockAndDo(ctx, WorkingLockKey(repoID), func(ctx context.Context) error { + return deleteRepositoryDirectlyLocked(ctx, repoID, ignoreOrgTeams...) + }) +} + +func deleteRepositoryDirectlyLocked(ctx context.Context, repoID int64, ignoreOrgTeams ...bool) error { ctx, committer, err := db.TxContext(ctx) if err != nil { return err @@ -98,6 +106,10 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams } needRewriteKeysFile := deleted > 0 + if err := unbindCodespacesFromRepository(ctx, repoID); err != nil { + return err + } + if err := deleteDBRepository(ctx, repoID); err != nil { return err } @@ -385,6 +397,32 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams return nil } +func unbindCodespacesFromRepository(ctx context.Context, repoID int64) error { + if _, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(new(codespace_model.UserSecretRepository)); err != nil { + return err + } + if _, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Cols("repo_id").Update(&codespace_model.Codespace{}); err != nil { + return err + } + var authorizations []*codespace_model.PermissionAuthorization + if err := db.GetEngine(ctx).Where("source_repo_id = ?", repoID).Find(&authorizations); err != nil { + return err + } + for _, authorization := range authorizations { + if _, err := db.GetEngine(ctx).Where("permission_authorization_id = ?", authorization.ID).Cols("permission_authorization_id").Update(&codespace_model.Codespace{}); err != nil { + return err + } + if _, err := db.GetEngine(ctx).Where("authorization_id = ?", authorization.ID).Delete(new(codespace_model.PermissionRepository)); err != nil { + return err + } + } + if _, err := db.GetEngine(ctx).Where("source_repo_id = ?", repoID).Delete(new(codespace_model.PermissionAuthorization)); err != nil { + return err + } + _, err := db.GetEngine(ctx).Where("target_repo_id = ?", repoID).Delete(new(codespace_model.PermissionRepository)) + return err +} + // DeleteOwnerRepositoriesDirectly calls DeleteRepositoryDirectly for all repos of the given owner func DeleteOwnerRepositoriesDirectly(ctx context.Context, owner *user_model.User) error { for { diff --git a/services/repository/transfer.go b/services/repository/transfer.go index 92825cb45b0b6..fa531504f490f 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -36,13 +36,14 @@ func IsRepositoryLimitReached(err error) bool { return ok } -func getRepoWorkingLockKey(repoID int64) string { +// WorkingLockKey returns the repository write-side coordination lock key. +func WorkingLockKey(repoID int64) string { return fmt.Sprintf("repo_working_%d", repoID) } // AcceptTransferOwnership transfers all corresponding setting from old user to new one. func AcceptTransferOwnership(ctx context.Context, repo *repo_model.Repository, doer *user_model.User) error { - releaser, err := globallock.Lock(ctx, getRepoWorkingLockKey(repo.ID)) + releaser, err := globallock.Lock(ctx, WorkingLockKey(repo.ID)) if err != nil { log.Error("lock.Lock(): %v", err) return fmt.Errorf("lock.Lock: %w", err) @@ -404,7 +405,7 @@ func ChangeRepositoryName(ctx context.Context, doer *user_model.User, repo *repo // repo so that we can automatically rename the repo path and updates the // local copy's origin accordingly. - releaser, err := globallock.Lock(ctx, getRepoWorkingLockKey(repo.ID)) + releaser, err := globallock.Lock(ctx, WorkingLockKey(repo.ID)) if err != nil { log.Error("lock.Lock(): %v", err) return fmt.Errorf("lock.Lock: %w", err) @@ -425,7 +426,7 @@ func ChangeRepositoryName(ctx context.Context, doer *user_model.User, repo *repo // StartRepositoryTransfer transfer a repo from one owner to a new one. // it make repository into pending transfer state, if doer can not create repo for new owner. func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository, teams []*organization.Team) error { - releaser, err := globallock.Lock(ctx, getRepoWorkingLockKey(repo.ID)) + releaser, err := globallock.Lock(ctx, WorkingLockKey(repo.ID)) if err != nil { return fmt.Errorf("lock.Lock: %w", err) } diff --git a/services/user/user.go b/services/user/user.go index d03809d882a76..814710fbdc6a9 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -24,6 +24,7 @@ import ( "gitea.dev/modules/util" "gitea.dev/services/agit" asymkey_service "gitea.dev/services/asymkey" + codespace_service "gitea.dev/services/codespace" org_service "gitea.dev/services/org" "gitea.dev/services/packages" container_service "gitea.dev/services/packages/container" @@ -150,6 +151,10 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { // FIXME: We also need to tell the session manager to log them out too. websocket_service.PublishLogout(u.ID, "") + if err := codespace_service.DeleteUserResources(ctx, u.ID); err != nil { + return err + } + // Delete all repos belonging to this user // Now this is not within a transaction because there are internal transactions within the DeleteRepository // BUT: the db will still be consistent even if a number of repos have already been deleted. @@ -207,43 +212,30 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { } } - if err := db.WithTx(ctx, func(ctx context.Context) error { - // Note: A user owns any repository or belongs to any organization - // cannot perform delete operation. This causes a race with the purge above - // however consistency requires that we ensure that this is the case - - // Check ownership of repository. - count, err := repo_model.CountRepositories(ctx, repo_model.CountRepositoryOptions{OwnerID: u.ID}) - if err != nil { - return fmt.Errorf("GetRepositoryCount: %w", err) - } else if count > 0 { - return repo_model.ErrUserOwnRepos{UID: u.ID} - } - - // Check membership of organization. - count, err = organization.GetOrganizationCount(ctx, u) - if err != nil { - return fmt.Errorf("GetOrganizationCount: %w", err) - } else if count > 0 { - return organization.ErrUserHasOrgs{UID: u.ID} - } - - // Check ownership of packages. - if ownsPackages, err := packages_model.HasOwnerPackages(ctx, u.ID); err != nil { - return fmt.Errorf("HasOwnerPackages: %w", err) - } else if ownsPackages { - return packages_model.ErrUserOwnPackages{UID: u.ID} + if !purge { + if err := checkDeleteUserPreconditions(ctx, u); err != nil { + return err } + } + if err := codespace_service.WithUserResourcesDeleted(ctx, u.ID, func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { + // Note: A user owns any repository or belongs to any organization + // cannot perform delete operation. This causes a race with the purge above + // however consistency requires that we ensure that this is the case + if err := checkDeleteUserPreconditions(ctx, u); err != nil { + return err + } - if err := deleteUser(ctx, u, purge); err != nil { - return fmt.Errorf("DeleteUser: %w", err) - } + if err := deleteUser(ctx, u, purge); err != nil { + return fmt.Errorf("DeleteUser: %w", err) + } - // Finally delete any unlinked attachments, this will also delete the attached files - if err := deleteUserUnlinkedAttachments(ctx, u); err != nil { - return fmt.Errorf("deleteUserUnlinkedAttachments: %w", err) - } - return nil + // Finally delete any unlinked attachments, this will also delete the attached files + if err := deleteUserUnlinkedAttachments(ctx, u); err != nil { + return fmt.Errorf("deleteUserUnlinkedAttachments: %w", err) + } + return nil + }) }); err != nil { return err } @@ -273,6 +265,29 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { return nil } +func checkDeleteUserPreconditions(ctx context.Context, u *user_model.User) error { + count, err := repo_model.CountRepositories(ctx, repo_model.CountRepositoryOptions{OwnerID: u.ID}) + if err != nil { + return fmt.Errorf("GetRepositoryCount: %w", err) + } else if count > 0 { + return repo_model.ErrUserOwnRepos{UID: u.ID} + } + + count, err = organization.GetOrganizationCount(ctx, u) + if err != nil { + return fmt.Errorf("GetOrganizationCount: %w", err) + } else if count > 0 { + return organization.ErrUserHasOrgs{UID: u.ID} + } + + if ownsPackages, err := packages_model.HasOwnerPackages(ctx, u.ID); err != nil { + return fmt.Errorf("HasOwnerPackages: %w", err) + } else if ownsPackages { + return packages_model.ErrUserOwnPackages{UID: u.ID} + } + return nil +} + func deleteUserUnlinkedAttachments(ctx context.Context, u *user_model.User) error { attachments, err := repo_model.GetUnlinkedAttachmentsByUserID(ctx, u.ID) if err != nil { diff --git a/services/user/user_test.go b/services/user/user_test.go index 3daf57639f92a..de02a92f7a278 100644 --- a/services/user/user_test.go +++ b/services/user/user_test.go @@ -11,6 +11,7 @@ import ( activities_model "gitea.dev/models/activities" "gitea.dev/models/auth" + codespace_model "gitea.dev/models/codespace" "gitea.dev/models/db" issues_model "gitea.dev/models/issues" "gitea.dev/models/organization" @@ -144,6 +145,56 @@ func TestCreateUser(t *testing.T) { assert.NoError(t, DeleteUser(t.Context(), user, false)) } +func TestDeleteUserCleansCodespaceOwnerResources(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + user := &user_model.User{ + Name: "CodespaceDeleteUser", + Email: "codespace-delete-user@gitea.io", + Passwd: "password", + IsAdmin: false, + Theme: setting.UI.DefaultTheme, + MustChangePassword: false, + } + require.NoError(t, user_model.CreateUser(t.Context(), user, &user_model.Meta{})) + manager := insertUserTestCodespaceManager(t, user.ID) + require.NoError(t, db.Insert(t.Context(), &codespace_model.ManagerToken{ + UserID: user.ID, + Token: "user-delete-codespace-token", + })) + codespaceUUID := "71717171-7171-4171-8171-717171717171" + require.NoError(t, db.Insert(t.Context(), &codespace_model.Codespace{ + UUID: codespaceUUID, + UserID: user.ID, + RepoID: 0, + ManagerID: manager.ID, + RefType: "branch", + RefName: "main", + EnvironmentTag: "default", + CommitSHA: "0123456789abcdef0123456789abcdef01234567", + Status: codespace_model.StatusRunning, + OperationRVersion: 71, + AutoStopMode: codespace_model.AutoStopModeDefault, + CreatedUnix: 1, + UpdatedUnix: 1, + })) + require.NoError(t, db.Insert(t.Context(), &codespace_model.GiteaToken{ + CodespaceUUID: codespaceUUID, + TokenHash: "user-delete-hash", + TokenSalt: "salt", + TokenLastEight: "last0071", + TokenEncrypted: "encrypted", + })) + + require.NoError(t, DeleteUser(t.Context(), user, false)) + + unittest.AssertNotExistsBean(t, &user_model.User{ID: user.ID}) + assertUserTestNotExists(t, new(codespace_model.Manager), "id = ?", manager.ID) + assertUserTestNotExists(t, new(codespace_model.ManagerToken), "user_id = ?", user.ID) + assertUserTestNotExists(t, new(codespace_model.Codespace), "uuid = ?", codespaceUUID) + assertUserTestNotExists(t, new(codespace_model.GiteaToken), "codespace_uuid = ?", codespaceUUID) +} + func TestRenameUser(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 21}) @@ -263,3 +314,25 @@ func TestDeleteInactiveUsers(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-active-10"}) unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-active-5"}) } + +func insertUserTestCodespaceManager(t *testing.T, userID int64) *codespace_model.Manager { + t.Helper() + manager := &codespace_model.Manager{ + Name: "user-delete-manager", + UserID: userID, + RuntimeState: codespace_model.ManagerRuntimeStateOnline, + TagsJSON: "[]", + CreatedUnix: 1, + LastOnlineUnix: 1, + } + manager.GenerateManagerSecret() + require.NoError(t, db.Insert(t.Context(), manager)) + return manager +} + +func assertUserTestNotExists(t *testing.T, bean any, query string, args ...any) { + t.Helper() + has, err := db.GetEngine(t.Context()).Where(query, args...).Exist(bean) + require.NoError(t, err) + assert.False(t, has) +} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index a54fa530e24fe..065097b963d16 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -87,6 +87,16 @@ {{end}} + {{if .EnableCodespaces}} +
+ {{ctx.Locale.Tr "codespace.title"}} + +
+ {{end}}
{{ctx.Locale.Tr "admin.config"}}
{{end}} {{if .EnableCodespaces}} -
+
{{ctx.Locale.Tr "codespace.title"}}
{{end}} diff --git a/templates/codespace/admin_devcontainer_templates.tmpl b/templates/codespace/admin_devcontainer_templates.tmpl new file mode 100644 index 0000000000000..00125800b49fa --- /dev/null +++ b/templates/codespace/admin_devcontainer_templates.tmpl @@ -0,0 +1,5 @@ +{{template "admin/layout_head" (dict "pageClass" "admin codespace devcontainer templates")}} +
+ {{template "codespace/devcontainer_templates_content" .}} +
+{{template "admin/layout_footer" .}} diff --git a/templates/codespace/create_confirm.tmpl b/templates/codespace/create_confirm.tmpl index d6bc89c8546dd..5ebb8198bc83b 100644 --- a/templates/codespace/create_confirm.tmpl +++ b/templates/codespace/create_confirm.tmpl @@ -65,7 +65,7 @@ {{range .CreatePlan.DevContainerOptions}} {{if and .Selected .Path}}
{{.Path}}
{{end}} diff --git a/templates/codespace/devcontainer_templates_content.tmpl b/templates/codespace/devcontainer_templates_content.tmpl new file mode 100644 index 0000000000000..35cdafb1e7faa --- /dev/null +++ b/templates/codespace/devcontainer_templates_content.tmpl @@ -0,0 +1,63 @@ +

+ {{ctx.Locale.Tr "codespace.dev_container_templates"}} +

+
+
+ {{.CsrfTokenHtml}} +
+ + +
+
+ + +
{{ctx.Locale.Tr "codespace.dev_container_template_content_help"}}
+
+ +
+
+ +

+ {{ctx.Locale.Tr "codespace.dev_container_templates_saved"}} +

+
+ {{if .DevContainerTemplates}} +
+ {{range .DevContainerTemplates}} +
+
{{svg "octicon-file-code" 32}}
+
+
{{.Name}}
+
{{DateUtils.AbsoluteShort .UpdatedUnix}}
+
+ {{ctx.Locale.Tr "edit"}} +
+ {{$.CsrfTokenHtml}} +
+ + +
+
+ + +
+ +
+
+
+
+
+ {{$.CsrfTokenHtml}} + + +
+
+
+ {{end}} +
+ {{else}} + {{ctx.Locale.Tr "codespace.no_dev_container_templates"}} + {{end}} +
diff --git a/templates/codespace/user_devcontainer_templates.tmpl b/templates/codespace/user_devcontainer_templates.tmpl new file mode 100644 index 0000000000000..d9531c4df475d --- /dev/null +++ b/templates/codespace/user_devcontainer_templates.tmpl @@ -0,0 +1,3 @@ +{{template "user/settings/layout_head" (dict "pageClass" "user settings codespace devcontainer templates")}} + {{template "codespace/devcontainer_templates_content" .}} +{{template "user/settings/layout_footer" .}} diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index 95d83278a0d7d..a8d41640b4f6a 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -60,10 +60,11 @@ {{ctx.Locale.Tr "packages.title"}} {{end}} -
+
{{ctx.Locale.Tr "codespace.title"}} diff --git a/tests/integration/codespace_test.go b/tests/integration/codespace_test.go index b7ccfa73adafe..9524c36622132 100644 --- a/tests/integration/codespace_test.go +++ b/tests/integration/codespace_test.go @@ -37,10 +37,12 @@ import ( func TestCodespaceRoutes(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + insertIntegrationDevContainerTemplate(t) MakeRequest(t, NewRequest(t, http.MethodGet, "/-/codespaces"), http.StatusSeeOther) user2Session := loginUser(t, "user2") user2Session.MakeRequest(t, NewRequest(t, http.MethodGet, "/user/settings/codespaces/managers"), http.StatusOK) + user2Session.MakeRequest(t, NewRequest(t, http.MethodGet, "/user/settings/codespaces/dev-container-templates"), http.StatusOK) user2Session.MakeRequest(t, NewRequest(t, http.MethodGet, "/user/settings/codespaces/permissions"), http.StatusOK) now := time.Now().Unix() authorization := &codespace_model.PermissionAuthorization{ @@ -89,6 +91,7 @@ func TestCodespaceRoutes(t *testing.T) { adminSession := loginUser(t, "user1") adminSession.MakeRequest(t, NewRequest(t, http.MethodGet, "/-/admin/codespaces/managers"), http.StatusOK) + adminSession.MakeRequest(t, NewRequest(t, http.MethodGet, "/-/admin/codespaces/dev-container-templates"), http.StatusOK) adminSession.MakeRequest(t, NewRequestf(t, http.MethodGet, "/-/admin/codespaces/managers/%d", manager.ID), http.StatusOK) user2Session.MakeRequest(t, NewRequest(t, http.MethodGet, "/-/admin/codespaces/managers"), http.StatusForbidden) user2Session.MakeRequest(t, NewRequest(t, http.MethodGet, "/org/org3/settings/codespaces"), http.StatusNotFound) @@ -221,6 +224,7 @@ func TestCodespaceTokenAPIRoutePolicy(t *testing.T) { func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + insertIntegrationDevContainerTemplate(t) manager, secret := createIntegrationManager(t) client := codespacev1connect.NewManagerServiceClient( http.DefaultClient, @@ -238,9 +242,9 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { require.Equal(t, codespace_model.OperationStatusQueued, row.OperationStatus) require.Equal(t, codespace_model.OperationTriggerUser, row.OperationTrigger) require.EqualValues(t, 1, row.OperationRVersion) + require.Equal(t, codespace_model.DevContainerSourceTemplate, row.DevContainerSource) require.Empty(t, row.DevContainerPath) - require.Empty(t, row.DevContainerContentSHA256) - require.Equal(t, setting.Codespace.DevContainerDefaultImage, row.DevContainerDefaultImage) + require.JSONEq(t, `{"image":"mcr.microsoft.com/devcontainers/base:ubuntu"}`, row.DevContainerContent) fetched, err := client.FetchOperations(t.Context(), codespaceManagerRequest(manager.ID, secret, &codespacev1.FetchOperationsRequest{ ProtocolVersion: 1, @@ -263,10 +267,10 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { ProtocolVersion: 1, CodespaceUuid: codespaceUUID, OperationRversion: 1, - GitSshPublicKey: integrationGitSSHPublicKey(t), + GitSshKey: &codespacev1.RuntimeGitSSHKey{PublicKey: integrationGitSSHPublicKey(t)}, })) require.NoError(t, err) - require.True(t, strings.HasPrefix(tokenResponse.Msg.GetToken(), "gcs_")) + require.True(t, strings.HasPrefix(tokenResponse.Msg.GetAccess().GetGiteaToken(), "gcs_")) _, err = client.FinalizeOperation(t.Context(), codespaceManagerRequest(manager.ID, secret, createFinalRequest(codespaceUUID, 1, codespacev1.OperationType_OPERATION_TYPE_CREATE, codespacev1.FinalStatus_FINAL_STATUS_DONE))) require.Error(t, err) @@ -355,7 +359,7 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { ProtocolVersion: 1, CodespaceUuid: codespaceUUID, OperationRversion: resumeVersion, - GitSshPublicKey: integrationGitSSHPublicKey(t), + GitSshKey: &codespacev1.RuntimeGitSSHKey{PublicKey: integrationGitSSHPublicKey(t)}, })) require.NoError(t, err) _, err = client.ReportRuntimeMetadata(t.Context(), codespaceManagerRequest(manager.ID, secret, &codespacev1.ReportRuntimeMetadataRequest{ @@ -416,7 +420,7 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { ProtocolVersion: 1, CodespaceUuid: codespaceUUID, OperationRversion: resumeVersion, - GitSshPublicKey: integrationGitSSHPublicKey(t), + GitSshKey: &codespacev1.RuntimeGitSSHKey{PublicKey: integrationGitSSHPublicKey(t)}, })) require.NoError(t, err) _, err = client.ReportRuntimeMetadata(t.Context(), codespaceManagerRequest(manager.ID, secret, &codespacev1.ReportRuntimeMetadataRequest{ @@ -450,6 +454,18 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { }) } +func insertIntegrationDevContainerTemplate(t *testing.T) { + t.Helper() + now := time.Now().Unix() + require.NoError(t, db.Insert(t.Context(), &codespace_model.DevContainerTemplate{ + UserID: 0, + Name: "Default", + Content: `{"image":"mcr.microsoft.com/devcontainers/base:ubuntu"}`, + CreatedUnix: now, + UpdatedUnix: now, + })) +} + func TestCodespaceInventoryStateMachineIntegration(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { manager, secret := createIntegrationManager(t) From 4eae97451807e10b3ee74118af3480c5d6095f45 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 4 Aug 2026 21:33:45 +0800 Subject: [PATCH 12/16] refactor(codespace): simplify Dev Container template UI Assisted-by: Codex:GPT-5 --- routers/web/codespace/settings.go | 8 +- services/codespace/devcontainer_template.go | 13 +--- .../codespace/devcontainer_template_test.go | 7 +- .../devcontainer_templates_content.tmpl | 75 ++++++++++--------- 4 files changed, 45 insertions(+), 58 deletions(-) diff --git a/routers/web/codespace/settings.go b/routers/web/codespace/settings.go index b0c1b5df9bb3e..260e7d8e7b518 100644 --- a/routers/web/codespace/settings.go +++ b/routers/web/codespace/settings.go @@ -207,9 +207,8 @@ func handleDevContainerTemplateUpsert(ctx *context.Context, opts devContainerTem func handleDevContainerTemplateDelete(ctx *context.Context, opts devContainerTemplateRenderOptions) { err := codespace_service.DeleteDevContainerTemplate(ctx, codespace_service.DevContainerTemplateDeleteOptions{ - UserID: opts.UserID, - ID: ctx.PathParamInt64("template_id"), - Confirm: ctx.FormString("confirm") == "delete-template", + UserID: opts.UserID, + ID: ctx.PathParamInt64("template_id"), }) if err != nil { handleDevContainerTemplateActionError(ctx, opts.ActionBase, err) @@ -222,9 +221,6 @@ func handleDevContainerTemplateActionError(ctx *context.Context, redirectTo stri switch { case errors.Is(err, codespace_service.ErrDevContainerTemplateNotFound): ctx.NotFound(nil) - case errors.Is(err, codespace_service.ErrDevContainerTemplateConfirmRequired): - ctx.Flash.Error(ctx.Tr("codespace.error.confirm_required")) - ctx.Redirect(redirectTo, http.StatusSeeOther) default: ctx.Flash.Error(err.Error()) ctx.Redirect(redirectTo, http.StatusSeeOther) diff --git a/services/codespace/devcontainer_template.go b/services/codespace/devcontainer_template.go index 524c9f0879006..32a15056069d3 100644 --- a/services/codespace/devcontainer_template.go +++ b/services/codespace/devcontainer_template.go @@ -13,10 +13,7 @@ import ( "gitea.dev/models/db" ) -var ( - ErrDevContainerTemplateNotFound = errors.New("codespace Dev Container template not found") - ErrDevContainerTemplateConfirmRequired = errors.New("codespace Dev Container template confirmation required") -) +var ErrDevContainerTemplateNotFound = errors.New("codespace Dev Container template not found") type DevContainerTemplateUpsertOptions struct { UserID int64 @@ -26,9 +23,8 @@ type DevContainerTemplateUpsertOptions struct { } type DevContainerTemplateDeleteOptions struct { - UserID int64 - ID int64 - Confirm bool + UserID int64 + ID int64 } func listVisibleDevContainerTemplates(ctx context.Context, userID int64) ([]*codespace_model.DevContainerTemplate, error) { @@ -89,9 +85,6 @@ func UpsertDevContainerTemplate(ctx context.Context, opts DevContainerTemplateUp } func DeleteDevContainerTemplate(ctx context.Context, opts DevContainerTemplateDeleteOptions) error { - if !opts.Confirm { - return ErrDevContainerTemplateConfirmRequired - } deleted, err := db.GetEngine(ctx).Where("id = ? AND user_id = ?", opts.ID, opts.UserID).Delete(new(codespace_model.DevContainerTemplate)) if err != nil { return err diff --git a/services/codespace/devcontainer_template_test.go b/services/codespace/devcontainer_template_test.go index a30481ffcf753..e0f0a99d75b4a 100644 --- a/services/codespace/devcontainer_template_test.go +++ b/services/codespace/devcontainer_template_test.go @@ -32,14 +32,9 @@ func TestDevContainerTemplateSettingsScope(t *testing.T) { Content: `{"image":"debian:13"}`, }), ErrDevContainerTemplateNotFound) - require.ErrorIs(t, DeleteDevContainerTemplate(t.Context(), DevContainerTemplateDeleteOptions{ + require.NoError(t, DeleteDevContainerTemplate(t.Context(), DevContainerTemplateDeleteOptions{ UserID: 2, ID: personal.ID, - }), ErrDevContainerTemplateConfirmRequired) - require.NoError(t, DeleteDevContainerTemplate(t.Context(), DevContainerTemplateDeleteOptions{ - UserID: 2, - ID: personal.ID, - Confirm: true, })) unittest.AssertNotExistsBean(t, &codespace_model.DevContainerTemplate{ID: personal.ID}) } diff --git a/templates/codespace/devcontainer_templates_content.tmpl b/templates/codespace/devcontainer_templates_content.tmpl index 35cdafb1e7faa..6e2eede71121c 100644 --- a/templates/codespace/devcontainer_templates_content.tmpl +++ b/templates/codespace/devcontainer_templates_content.tmpl @@ -1,54 +1,38 @@

{{ctx.Locale.Tr "codespace.dev_container_templates"}} -

-
-
- {{.CsrfTokenHtml}} -
- - -
-
- - -
{{ctx.Locale.Tr "codespace.dev_container_template_content_help"}}
-
- -
-
- -

- {{ctx.Locale.Tr "codespace.dev_container_templates_saved"}} +
+ +

{{if .DevContainerTemplates}}
{{range .DevContainerTemplates}} -
+
{{svg "octicon-file-code" 32}}
{{.Name}}
{{DateUtils.AbsoluteShort .UpdatedUnix}}
-
- {{ctx.Locale.Tr "edit"}} -
- {{$.CsrfTokenHtml}} -
- - -
-
- - -
- -
-
+
{{$.CsrfTokenHtml}} - @@ -61,3 +45,22 @@ {{ctx.Locale.Tr "codespace.no_dev_container_templates"}} {{end}}
+ + From e0ee46ff977061df6aef7d777d8499c4a8c2fde0 Mon Sep 17 00:00:00 2001 From: dragon Date: Fri, 7 Aug 2026 02:17:22 +0800 Subject: [PATCH 13/16] feat: refine codespace manager identity flow Assisted-by: Codex:gpt-5 --- go.mod | 2 +- go.sum | 4 +- modelmigration/v1_28/v347.go | 12 +- modelmigration/v1_28/v347_test.go | 10 +- models/codespace/codespace.go | 23 +- options/locale/locale_en-US.json | 7 +- routers/api/codespace/manager/interceptor.go | 21 +- routers/api/codespace/manager/manager.go | 74 +++---- routers/api/codespace/manager/manager_test.go | 78 +++---- routers/web/codespace/action.go | 58 +++-- routers/web/codespace/action_test.go | 24 +-- routers/web/codespace/create.go | 2 +- routers/web/codespace/log.go | 35 +-- routers/web/codespace/log_test.go | 37 ++-- routers/web/codespace/open.go | 12 +- routers/web/codespace/open_test.go | 13 +- routers/web/codespace/settings.go | 41 ++-- routers/web/codespace/view.go | 8 +- routers/web/codespace/view_test.go | 34 ++- routers/web/web.go | 6 +- services/codespace/create.go | 6 +- services/codespace/create_test.go | 12 +- services/codespace/disabled_test.go | 24 +-- services/codespace/fetch.go | 22 +- services/codespace/fetch_test.go | 26 +-- services/codespace/governance.go | 2 + services/codespace/idle_stop.go | 5 + services/codespace/interaction.go | 29 +-- services/codespace/interaction_test.go | 40 ++-- services/codespace/inventory.go | 16 +- services/codespace/inventory_test.go | 32 +-- services/codespace/lifecycle_action.go | 13 +- services/codespace/lifecycle_action_test.go | 22 +- services/codespace/lifecycle_test.go | 14 ++ services/codespace/log.go | 17 +- services/codespace/log_test.go | 44 ++-- services/codespace/manager.go | 131 ++++-------- services/codespace/manager_test.go | 136 ++++++++++++ services/codespace/open_token.go | 28 +-- services/codespace/open_token_test.go | 32 +-- services/codespace/session_revalidate.go | 4 +- services/codespace/session_revalidate_test.go | 12 +- services/codespace/settings.go | 201 +++++------------- services/codespace/settings_test.go | 74 +------ services/codespace/user_delete.go | 5 +- services/codespace/user_delete_test.go | 12 +- services/codespace/view.go | 20 +- services/codespace/view_test.go | 17 +- services/user/user_test.go | 5 - templates/codespace/detail.tmpl | 4 +- templates/codespace/list_rows.tmpl | 14 +- templates/codespace/manager_codespaces.tmpl | 4 +- templates/codespace/settings_content.tmpl | 34 ++- templates/codespace/state.tmpl | 18 +- templates/repo/codespace_panel.tmpl | 8 +- tests/integration/codespace_test.go | 118 +++++----- 56 files changed, 845 insertions(+), 857 deletions(-) diff --git a/go.mod b/go.mod index 82a016721b29a..63ee9f05c472f 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 gitea.dev/actions-proto-go v0.6.0 - gitea.dev/codespace-proto-go v0.0.0-20260804130147-8618625fc14d + gitea.dev/codespace-proto-go v0.0.0-20260806181530-58e42686428c gitea.dev/sdk v1.2.0 github.com/42wim/httpsig v1.2.4 github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 diff --git a/go.sum b/go.sum index eeaf4b5c17b07..4ce062c2f4786 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,8 @@ gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGq gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= gitea.dev/actions-proto-go v0.6.0 h1:gjllYQ5vmwlkqOeofTQu5qKTZpmf7kWsafoHvoPCSzY= gitea.dev/actions-proto-go v0.6.0/go.mod h1:p4RX+D9oqiEEzzkPMXscw2CmaGuYFPWFc6xIOmDNDqs= -gitea.dev/codespace-proto-go v0.0.0-20260804130147-8618625fc14d h1:hvc/YWVNWdrw12vMTKBegcCfZ1C4dSJXy6ONKDzH3mE= -gitea.dev/codespace-proto-go v0.0.0-20260804130147-8618625fc14d/go.mod h1:PtyeQBWy45uIqw8bS6jPK2LYSizpS+W3vtvZ+7E9s10= +gitea.dev/codespace-proto-go v0.0.0-20260806181530-58e42686428c h1:Ap/cecRYu7GE9yGlINiapCbhmqSkoF3dQ0KPbZlWfwA= +gitea.dev/codespace-proto-go v0.0.0-20260806181530-58e42686428c/go.mod h1:PtyeQBWy45uIqw8bS6jPK2LYSizpS+W3vtvZ+7E9s10= gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo= gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= diff --git a/modelmigration/v1_28/v347.go b/modelmigration/v1_28/v347.go index 163c832c01446..9d77f0e397e76 100644 --- a/modelmigration/v1_28/v347.go +++ b/modelmigration/v1_28/v347.go @@ -14,7 +14,7 @@ import ( type codespace struct { ID int64 - UUID string `xorm:"CHAR(36) NOT NULL UNIQUE"` + UUID string `xorm:"CHAR(36) NOT NULL DEFAULT '' index"` UserID int64 `xorm:"NOT NULL DEFAULT 0"` RepoID int64 `xorm:"NOT NULL DEFAULT 0"` RefType string `xorm:"VARCHAR(16) NOT NULL DEFAULT ''"` @@ -103,13 +103,8 @@ func (*codespaceManager) TableIndices() []*schemas.Index { func AddCodespaceTables(_ context.Context, x base.EngineMigration) error { type codespaceManagerAddress struct { ManagerID int64 `xorm:"pk NOT NULL DEFAULT 0"` - Kind string `xorm:"pk VARCHAR(16) NOT NULL DEFAULT '' unique(kind_address)"` - Address string `xorm:"VARCHAR(512) NOT NULL DEFAULT '' unique(kind_address)"` - } - - type codespaceManagerToken struct { - Token string `xorm:"VARCHAR(64) NOT NULL UNIQUE"` - UserID int64 `xorm:"pk NOT NULL DEFAULT 0"` + Kind string `xorm:"pk VARCHAR(16) NOT NULL DEFAULT '' index(kind_address)"` + Address string `xorm:"VARCHAR(512) NOT NULL DEFAULT '' index(kind_address)"` } type codespaceGiteaToken struct { @@ -177,7 +172,6 @@ func AddCodespaceTables(_ context.Context, x base.EngineMigration) error { new(codespace), new(codespaceManager), new(codespaceManagerAddress), - new(codespaceManagerToken), new(codespaceGiteaToken), new(codespaceSSHKey), new(codespacePermissionAuthorization), diff --git a/modelmigration/v1_28/v347_test.go b/modelmigration/v1_28/v347_test.go index a8670a30d3a12..0b473bf68666d 100644 --- a/modelmigration/v1_28/v347_test.go +++ b/modelmigration/v1_28/v347_test.go @@ -29,7 +29,6 @@ func Test_AddCodespaceTables(t *testing.T) { "codespace", "codespace_manager", "codespace_manager_address", - "codespace_manager_token", "codespace_gitea_token", "codespace_ssh_key", "codespace_permission_authorization", @@ -46,7 +45,7 @@ func Test_AddCodespaceTables(t *testing.T) { require.NoError(t, err) assert.True(t, hasIndex(codespaceIndexes, "user_id", "updated_unix", "created_unix", "id")) assert.True(t, hasIndex(codespaceIndexes, "repo_id")) - assert.True(t, hasUniqueIndex(codespaceIndexes, "uuid")) + assert.True(t, hasIndex(codespaceIndexes, "uuid")) assertPrimaryKeyColumns(t, x, "codespace", "id") assert.True(t, hasIndex(codespaceIndexes, "status", "operation_type", "operation_status", "manager_id", "environment_tag", "operation_created_unix", "id")) assert.True(t, hasIndex(codespaceIndexes, "manager_id", "operation_status", "operation_created_unix", "id")) @@ -60,14 +59,9 @@ func Test_AddCodespaceTables(t *testing.T) { addressIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_manager_address") require.NoError(t, err) - assert.True(t, hasUniqueIndex(addressIndexes, "kind", "address")) + assert.True(t, hasIndex(addressIndexes, "kind", "address")) assertPrimaryKeyColumns(t, x, "codespace_manager_address", "manager_id", "kind") - managerTokenIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_manager_token") - require.NoError(t, err) - assert.True(t, hasUniqueIndex(managerTokenIndexes, "token")) - assertPrimaryKeyColumns(t, x, "codespace_manager_token", "user_id") - giteaTokenIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_gitea_token") require.NoError(t, err) assert.True(t, hasUniqueIndex(giteaTokenIndexes, "token_hash")) diff --git a/models/codespace/codespace.go b/models/codespace/codespace.go index e20bf845707b5..9007d5d921cf0 100644 --- a/models/codespace/codespace.go +++ b/models/codespace/codespace.go @@ -85,7 +85,7 @@ const GiteaTokenAuthDataKey = "CodespaceToken" // Codespace stores Gitea-owned lifecycle state for one remote development environment. type Codespace struct { ID int64 - UUID string `xorm:"CHAR(36) NOT NULL UNIQUE"` + UUID string `xorm:"CHAR(36) NOT NULL DEFAULT '' index"` UserID int64 `xorm:"NOT NULL DEFAULT 0"` RepoID int64 `xorm:"NOT NULL DEFAULT 0"` RefType string `xorm:"VARCHAR(16) NOT NULL DEFAULT ''"` @@ -136,14 +136,8 @@ type Manager struct { // ManagerAddress stores current routable addresses declared by a Manager. type ManagerAddress struct { ManagerID int64 `xorm:"pk NOT NULL DEFAULT 0"` - Kind string `xorm:"pk VARCHAR(16) NOT NULL DEFAULT '' unique(kind_address)"` - Address string `xorm:"VARCHAR(512) NOT NULL DEFAULT '' unique(kind_address)"` -} - -// ManagerToken stores the current site-wide or user-scoped Manager registration token. -type ManagerToken struct { - Token string `xorm:"VARCHAR(64) NOT NULL UNIQUE"` - UserID int64 `xorm:"pk NOT NULL DEFAULT 0"` + Kind string `xorm:"pk VARCHAR(16) NOT NULL DEFAULT '' index(kind_address)"` + Address string `xorm:"VARCHAR(512) NOT NULL DEFAULT '' index(kind_address)"` } // GiteaToken stores the current Gitea API/Git HTTP token for one Codespace. @@ -212,10 +206,6 @@ func (*ManagerAddress) TableName() string { return "codespace_manager_address" } -func (*ManagerToken) TableName() string { - return "codespace_manager_token" -} - func (*GiteaToken) TableName() string { return "codespace_gitea_token" } @@ -232,7 +222,6 @@ func init() { db.RegisterModel(new(Codespace)) db.RegisterModel(new(Manager)) db.RegisterModel(new(ManagerAddress)) - db.RegisterModel(new(ManagerToken)) db.RegisterModel(new(GiteaToken)) db.RegisterModel(new(SSHKey)) db.RegisterModel(new(DevContainerTemplate)) @@ -299,8 +288,10 @@ func ValidateCodespace(codespace *Codespace) error { if codespace == nil { return errors.New("codespace is nil") } - if err := ValidateUUID(codespace.UUID); err != nil { - return err + if codespace.UUID != "" { + if err := ValidateUUID(codespace.UUID); err != nil { + return err + } } if !validStatus(codespace.Status) { return fmt.Errorf("invalid codespace status %q", codespace.Status) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 12ffa014262f1..f33b1af24da62 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3055,7 +3055,7 @@ "codespace.environment_source_both": "Site and personal", "codespace.environment_source_personal": "Personal", "codespace.environment_source_site": "Site", - "codespace.environment_unavailable": "No Codespace environment is currently configured for your account. Ask an administrator to register a site Manager or register a personal Manager.", + "codespace.environment_unavailable": "No Codespace environment is currently configured for your account. Ask an administrator to create a site Manager identity or create a personal Manager identity.", "codespace.environment_description_conflict": "Managers declare different descriptions for these environment tags: %s. Creation remains available, but the descriptions should be aligned.", "codespace.enter_fullscreen": "Enter fullscreen", "codespace.error.confirm_required": "Confirm the action before submitting.", @@ -3125,8 +3125,7 @@ "codespace.recent": "Recent Codespaces", "codespace.recommended_secrets": "Recommended secrets", "codespace.recommended_secrets_description": "The selected Dev Container recommends these environment variables. Existing values remain hidden. You can allow this repository or provide an optional missing value before creation.", - "codespace.registration_token": "Registration token", - "codespace.registration_token_reset": "Registration token has been reset.", + "codespace.manager_secret_created": "Manager %d was created. Copy this secret now; it will not be shown again.", "codespace.repo_empty": "No codespaces for this repository.", "codespace.repository": "Repository", "codespace.repository_access": "Repository access", @@ -3135,8 +3134,6 @@ "codespace.granted": "Granted", "codespace.reduce_to_read": "Reduce to read", "codespace.revoke": "Revoke", - "codespace.reset_registration_token": "Reset registration token", - "codespace.reset_registration_token_confirm": "Reset registration token?", "codespace.resume": "Resume", "codespace.resume_confirm": "The runtime environment will start and become available when initialization completes.", "codespace.resume_title": "Resume this Codespace?", diff --git a/routers/api/codespace/manager/interceptor.go b/routers/api/codespace/manager/interceptor.go index 67819c16861e5..b198102fd351c 100644 --- a/routers/api/codespace/manager/interceptor.go +++ b/routers/api/codespace/manager/interceptor.go @@ -11,7 +11,6 @@ import ( "strconv" codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" - "gitea.dev/codespace-proto-go/codespace/v1/codespacev1connect" codespace_model "gitea.dev/models/codespace" "gitea.dev/modules/setting" codespace_service "gitea.dev/services/codespace" @@ -33,18 +32,16 @@ type versionedRequest interface { var withManager = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) { - if request.Spec().Procedure != codespacev1connect.ManagerServiceRegisterManagerProcedure { - manager, err := authenticate(ctx, request.Header()) - if err != nil { - switch { - case errors.Is(err, codespace_service.ErrManagerUnregistered): - return nil, failureError(connect.CodeUnauthenticated, "manager_unregistered", err) - default: - return nil, failureError(connect.CodeUnauthenticated, "unauthenticated", err) - } + manager, err := authenticate(ctx, request.Header()) + if err != nil { + switch { + case errors.Is(err, codespace_service.ErrManagerUnregistered): + return nil, failureError(connect.CodeUnauthenticated, "manager_unregistered", err) + default: + return nil, failureError(connect.CodeUnauthenticated, "unauthenticated", err) } - ctx = context.WithValue(ctx, managerCtxKey{}, manager) } + ctx = context.WithValue(ctx, managerCtxKey{}, manager) if err := validateProtocolVersion(request.Any()); err != nil { return nil, failureError(connect.CodeFailedPrecondition, "protocol_mismatch", err) } @@ -63,7 +60,7 @@ var withManager = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(una } })) -// GetManager returns the Manager authenticated before every RPC except RegisterManager. +// GetManager returns the Manager authenticated before each ManagerService RPC. func GetManager(ctx context.Context) *codespace_model.Manager { if value := ctx.Value(managerCtxKey{}); value != nil { if manager, ok := value.(*codespace_model.Manager); ok { diff --git a/routers/api/codespace/manager/manager.go b/routers/api/codespace/manager/manager.go index f1187833468b1..13e609aafc767 100644 --- a/routers/api/codespace/manager/manager.go +++ b/routers/api/codespace/manager/manager.go @@ -33,24 +33,6 @@ type Service struct { codespacev1connect.UnimplementedManagerServiceHandler } -// RegisterManager exchanges a registration token for a Manager identity. -func (s *Service) RegisterManager( - ctx context.Context, - req *connect.Request[codespacev1.RegisterManagerRequest], -) (*connect.Response[codespacev1.RegisterManagerResponse], error) { - manager, secret, err := codespace_service.RegisterManager(ctx, req.Msg.GetRegistrationToken()) - if err != nil { - return nil, serviceFailureError(err, connect.CodeInternal, "internal_error", []serviceErrorCase{ - {target: codespace_service.ErrRegistrationUnauthenticated, code: connect.CodeUnauthenticated, category: "unauthenticated"}, - {target: codespace_service.ErrRegistrationStateUnavailable, code: connect.CodeFailedPrecondition, category: "state_unavailable"}, - }) - } - return connect.NewResponse(&codespacev1.RegisterManagerResponse{ - ManagerId: manager.ID, - ManagerSecret: secret, - }), nil -} - // DeclareManager stores the authenticated Manager's current declaration. func (s *Service) DeclareManager( ctx context.Context, @@ -68,10 +50,7 @@ func (s *Service) DeclareManager( GatewaySSHHostKeyFingerprintSHA256: req.Msg.GetGatewaySshHostKeyFingerprintSha256(), GatewaySSHHostKeyUpdatedUnix: req.Msg.GetGatewaySshHostKeyUpdatedUnix(), }); err != nil { - return nil, serviceFailureError(err, connect.CodeInvalidArgument, "invalid_declaration", []serviceErrorCase{ - {target: codespace_service.ErrDeclareGatewayURLConflict, code: connect.CodeFailedPrecondition, category: "gateway_url_conflict"}, - {target: codespace_service.ErrDeclareGatewaySSHAddrConflict, code: connect.CodeFailedPrecondition, category: "gateway_ssh_addr_conflict"}, - }) + return nil, serviceFailureError(err, "invalid_declaration", nil) } heartbeatMillis, metadataRefreshMillis, maxMessageBytes, giteaWebURL := codespace_service.ManagerServiceTimings() return connect.NewResponse(&codespacev1.DeclareManagerResponse{ @@ -96,7 +75,7 @@ func (s *Service) FetchOperations( CleanupCapacityAvailable: req.Msg.GetCleanupCapacityAvailable(), }) if err != nil { - return nil, serviceFailureError(err, connect.CodeInvalidArgument, "invalid_argument", []serviceErrorCase{ + return nil, serviceFailureError(err, "invalid_argument", []serviceErrorCase{ {target: codespace_service.ErrFetchStateHistoryConflict, code: connect.CodeFailedPrecondition, category: "state_history_conflict"}, {target: codespace_service.ErrFetchManagerUnavailable, code: connect.CodeUnavailable, category: "manager_offline"}, }) @@ -104,6 +83,27 @@ func (s *Service) FetchOperations( return connect.NewResponse(result), nil } +// BindRuntimeIdentity stores the Manager-allocated runtime UUID for an active create operation. +func (s *Service) BindRuntimeIdentity( + ctx context.Context, + req *connect.Request[codespacev1.BindRuntimeIdentityRequest], +) (*connect.Response[codespacev1.BindRuntimeIdentityResponse], error) { + manager := GetManager(ctx) + runtimeUUID, err := codespace_service.BindRuntimeIdentity(ctx, manager, codespace_service.BindRuntimeIdentityOptions{ + CodespaceID: req.Msg.GetCodespaceId(), + OperationRVersion: req.Msg.GetOperationRversion(), + RuntimeUUID: req.Msg.GetRuntimeUuid(), + }) + if err != nil { + return nil, serviceFailureError(err, "invalid_argument", []serviceErrorCase{ + {target: codespace_service.ErrBindRuntimeIdentityNotFound, code: connect.CodeNotFound, category: "codespace_not_found"}, + {target: codespace_service.ErrBindRuntimeIdentityStateConflict, code: connect.CodeFailedPrecondition, category: "state_conflict"}, + {target: codespace_service.ErrBindRuntimeIdentityConflict, code: connect.CodeFailedPrecondition, category: "runtime_uuid_conflict"}, + }) + } + return connect.NewResponse(&codespacev1.BindRuntimeIdentityResponse{RuntimeUuid: runtimeUUID}), nil +} + // ReportInstances accepts a complete Runtime inventory snapshot. func (s *Service) ReportInstances( ctx context.Context, @@ -137,13 +137,13 @@ func (s *Service) FinalizeOperation( ) (*connect.Response[codespacev1.FinalizeOperationResponse], error) { manager := GetManager(ctx) response, err := codespace_service.FinalizeOperation(ctx, manager, codespace_service.FinalizeOperationOptions{ - CodespaceUUID: req.Msg.GetCodespaceUuid(), + CodespaceUUID: req.Msg.GetRuntimeUuid(), OperationRVersion: req.Msg.GetOperationRversion(), OperationType: req.Msg.GetOperationType(), FinalStatus: req.Msg.GetStatus(), }) if err != nil { - return nil, serviceFailureError(err, connect.CodeInvalidArgument, "invalid_argument", []serviceErrorCase{ + return nil, serviceFailureError(err, "invalid_argument", []serviceErrorCase{ {target: codespace_service.ErrFinalizeGiteaTokenRequired, code: connect.CodeFailedPrecondition, category: "gitea_token_required"}, {target: codespace_service.ErrFinalizeMetadataRequired, code: connect.CodeFailedPrecondition, category: "metadata_required"}, }) @@ -158,7 +158,7 @@ func (s *Service) UpdateLog( ) (*connect.Response[codespacev1.UpdateLogResponse], error) { manager := GetManager(ctx) result, err := codespace_service.UpdateLog(ctx, manager, codespace_service.UpdateLogOptions{ - CodespaceUUID: req.Msg.GetCodespaceUuid(), + CodespaceUUID: req.Msg.GetRuntimeUuid(), OperationRVersion: req.Msg.GetOperationRversion(), Offset: req.Msg.GetOffset(), Lines: req.Msg.GetLines(), @@ -190,7 +190,7 @@ func (s *Service) ReportRuntimeMetadata( ) (*connect.Response[codespacev1.ReportRuntimeMetadataResponse], error) { manager := GetManager(ctx) err := codespace_service.ReportRuntimeMetadata(ctx, manager, codespace_service.ReportRuntimeMetadataOptions{ - CodespaceUUID: req.Msg.GetCodespaceUuid(), + CodespaceUUID: req.Msg.GetRuntimeUuid(), Metadata: req.Msg.GetMetadata(), MetadataGeneration: req.Msg.GetMetadataGeneration(), }) @@ -207,7 +207,7 @@ func (s *Service) ReportRuntimeTransition( ) (*connect.Response[codespacev1.ReportRuntimeTransitionResponse], error) { manager := GetManager(ctx) err := codespace_service.ReportRuntimeTransition(ctx, manager, codespace_service.ReportRuntimeTransitionOptions{ - CodespaceUUID: req.Msg.GetCodespaceUuid(), + CodespaceUUID: req.Msg.GetRuntimeUuid(), RuntimeGeneration: req.Msg.GetRuntimeGeneration(), ObservedOperationRVersion: req.Msg.GetObservedOperationRversion(), RuntimeState: req.Msg.GetRuntimeState(), @@ -251,16 +251,16 @@ func reportRuntimeError(err error, cases []serviceErrorCase) error { if errors.As(err, &staleGeneration) { return failureErrorWithStaleGeneration(connect.CodeFailedPrecondition, "stale_generation", staleGeneration.CurrentGeneration, err) } - return serviceFailureError(err, connect.CodeInvalidArgument, "invalid_argument", cases) + return serviceFailureError(err, "invalid_argument", cases) } -func serviceFailureError(err error, fallbackCode connect.Code, fallbackCategory string, cases []serviceErrorCase) error { +func serviceFailureError(err error, fallbackCategory string, cases []serviceErrorCase) error { for _, errCase := range cases { if errors.Is(err, errCase.target) { return failureError(errCase.code, errCase.category, err) } } - return failureError(fallbackCode, fallbackCategory, err) + return failureError(connect.CodeInvalidArgument, fallbackCategory, err) } // RequestRuntimeAccess returns the authenticated Manager's current runtime access material. @@ -270,12 +270,12 @@ func (s *Service) RequestRuntimeAccess( ) (*connect.Response[codespacev1.RequestRuntimeAccessResponse], error) { manager := GetManager(ctx) result, err := codespace_service.RequestRuntimeAccess(ctx, manager, codespace_service.RequestRuntimeAccessOptions{ - CodespaceUUID: req.Msg.GetCodespaceUuid(), + CodespaceUUID: req.Msg.GetRuntimeUuid(), OperationRVersion: req.Msg.GetOperationRversion(), GitSSHPublicKey: req.Msg.GetGitSshKey().GetPublicKey(), }) if err != nil { - return nil, serviceFailureError(err, connect.CodeInvalidArgument, "invalid_argument", []serviceErrorCase{ + return nil, serviceFailureError(err, "invalid_argument", []serviceErrorCase{ {target: codespace_service.ErrRequestRuntimeAccessNotFound, code: connect.CodeNotFound, category: "codespace_not_found"}, {target: codespace_service.ErrRequestRuntimeAccessManagerMismatch, code: connect.CodeFailedPrecondition, category: "manager_mismatch"}, {target: codespace_service.ErrRequestRuntimeAccessStateUnavailable, code: connect.CodeFailedPrecondition, category: "state_unavailable"}, @@ -309,13 +309,13 @@ func (s *Service) RequestIdleStop( manager := GetManager(ctx) settings := req.Msg.GetObservedSettings() result, err := codespace_service.RequestIdleStop(ctx, manager, codespace_service.RequestIdleStopOptions{ - CodespaceUUID: req.Msg.GetCodespaceUuid(), + CodespaceUUID: req.Msg.GetRuntimeUuid(), ObservedAutoStopEnabled: settings.GetAutoStopEnabled(), ObservedIdleTimeoutSeconds: settings.GetIdleTimeoutSeconds(), ObservedInteractionGeneration: settings.GetInteractionGeneration(), }) if err != nil { - return nil, serviceFailureError(err, connect.CodeInvalidArgument, "invalid_argument", []serviceErrorCase{ + return nil, serviceFailureError(err, "invalid_argument", []serviceErrorCase{ {target: codespace_service.ErrRequestIdleStopNotFound, code: connect.CodeNotFound, category: "codespace_not_found"}, {target: codespace_service.ErrRequestIdleStopManagerMismatch, code: connect.CodeFailedPrecondition, category: "manager_mismatch"}, {target: codespace_service.ErrRequestIdleStopManagerUnavailable, code: connect.CodeUnavailable, category: "manager_offline"}, @@ -332,7 +332,7 @@ func (s *Service) ValidatePublicEndpoint( ) (*connect.Response[codespacev1.ValidatePublicEndpointResponse], error) { manager := GetManager(ctx) result, err := codespace_service.ValidatePublicEndpoint(ctx, manager, codespace_service.ValidatePublicEndpointOptions{ - CodespaceUUID: req.Msg.GetCodespaceUuid(), + CodespaceUUID: req.Msg.GetRuntimeUuid(), EndpointID: req.Msg.GetEndpointId(), }) if err != nil { @@ -363,7 +363,7 @@ func (s *Service) VerifySSHPublicKey( ) (*connect.Response[codespacev1.VerifySSHPublicKeyResponse], error) { manager := GetManager(ctx) result, err := codespace_service.VerifySSHPublicKey(ctx, manager, codespace_service.VerifySSHPublicKeyOptions{ - CodespaceUUID: req.Msg.GetCodespaceUuid(), + CodespaceUUID: req.Msg.GetRuntimeUuid(), PublicKey: req.Msg.GetPublicKey(), }) if err != nil { diff --git a/routers/api/codespace/manager/manager_test.go b/routers/api/codespace/manager/manager_test.go index 2e8ee2d5f1469..67916f136d3d5 100644 --- a/routers/api/codespace/manager/manager_test.go +++ b/routers/api/codespace/manager/manager_test.go @@ -18,6 +18,7 @@ import ( "gitea.dev/models/unittest" "gitea.dev/modules/setting" "gitea.dev/modules/test" + codespace_service "gitea.dev/services/codespace" "connectrpc.com/connect" "github.com/stretchr/testify/assert" @@ -25,38 +26,15 @@ import ( "google.golang.org/protobuf/proto" ) -func TestManagerServiceProtocolAuthenticationAndRegistration(t *testing.T) { +func TestManagerServiceProtocolAuthenticationAndDeclaration(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) client, cleanup := newManagerTestClient(t) defer cleanup() - _, err := client.RegisterManager(t.Context(), connect.NewRequest(&codespacev1.RegisterManagerRequest{ - ProtocolVersion: 0, - RegistrationToken: "missing", - })) - require.Error(t, err) - assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) - assert.Equal(t, "protocol_mismatch", failureCategory(t, err)) - - _, err = client.RegisterManager(t.Context(), connect.NewRequest(&codespacev1.RegisterManagerRequest{ - ProtocolVersion: 1, - RegistrationToken: "missing", - })) - require.Error(t, err) - assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) - assert.Equal(t, "unauthenticated", failureCategory(t, err)) - - require.NoError(t, db.Insert(t.Context(), &codespace_model.ManagerToken{ - Token: "registration-token", - UserID: 0, - })) - registered, err := client.RegisterManager(t.Context(), connect.NewRequest(&codespacev1.RegisterManagerRequest{ - ProtocolVersion: 1, - RegistrationToken: "registration-token", - })) + created, err := codespace_service.CreateManager(t.Context(), codespace_service.ManagerSettingsOptions{Scope: codespace_service.ManagerSettingsScopeSite}) require.NoError(t, err) - require.Positive(t, registered.Msg.GetManagerId()) - require.Len(t, registered.Msg.GetManagerSecret(), 64) + require.Positive(t, created.ManagerID) + require.NotEmpty(t, created.Secret) declaration := &codespacev1.DeclareManagerRequest{ ProtocolVersion: 1, @@ -70,24 +48,24 @@ func TestManagerServiceProtocolAuthenticationAndRegistration(t *testing.T) { GatewaySshHostKeyFingerprintSha256: " SHA256:test ", GatewaySshHostKeyUpdatedUnix: 1, } - _, err = client.DeclareManager(t.Context(), managerRequest(registered.Msg.GetManagerId(), "bad-secret", declaration)) + _, err = client.DeclareManager(t.Context(), managerRequest(created.ManagerID, "bad-secret", declaration)) require.Error(t, err) assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) assert.Equal(t, "unauthenticated", failureCategory(t, err)) - _, err = client.DeclareManager(t.Context(), managerRequest(registered.Msg.GetManagerId()+1000, registered.Msg.GetManagerSecret(), declaration)) + _, err = client.DeclareManager(t.Context(), managerRequest(created.ManagerID+1000, created.Secret, declaration)) require.Error(t, err) assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) assert.Equal(t, "manager_unregistered", failureCategory(t, err)) declaration.ProtocolVersion = 0 - _, err = client.DeclareManager(t.Context(), managerRequest(registered.Msg.GetManagerId(), registered.Msg.GetManagerSecret(), declaration)) + _, err = client.DeclareManager(t.Context(), managerRequest(created.ManagerID, created.Secret, declaration)) require.Error(t, err) assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) assert.Equal(t, "protocol_mismatch", failureCategory(t, err)) declaration.ProtocolVersion = 1 - declared, err := client.DeclareManager(t.Context(), managerRequest(registered.Msg.GetManagerId(), registered.Msg.GetManagerSecret(), declaration)) + declared, err := client.DeclareManager(t.Context(), managerRequest(created.ManagerID, created.Secret, declaration)) require.NoError(t, err) assert.Positive(t, declared.Msg.GetHeartbeatIntervalMilliseconds()) assert.Positive(t, declared.Msg.GetRuntimeMetadataRefreshIntervalMilliseconds()) @@ -95,7 +73,7 @@ func TestManagerServiceProtocolAuthenticationAndRegistration(t *testing.T) { assert.NotEmpty(t, declared.Msg.GetGiteaWebUrl()) manager := new(codespace_model.Manager) - has, err := db.GetEngine(t.Context()).ID(registered.Msg.GetManagerId()).Get(manager) + has, err := db.GetEngine(t.Context()).ID(created.ManagerID).Get(manager) require.NoError(t, err) require.True(t, has) assert.Equal(t, "manager-one", manager.Name) @@ -120,9 +98,9 @@ func TestManagerServiceProtocolAuthenticationAndRegistration(t *testing.T) { func TestManagerServiceRequestProtocolVersionFieldNumbers(t *testing.T) { requests := []proto.Message{ - &codespacev1.RegisterManagerRequest{}, &codespacev1.DeclareManagerRequest{}, &codespacev1.FetchOperationsRequest{}, + &codespacev1.BindRuntimeIdentityRequest{}, &codespacev1.ReportInstancesRequest{}, &codespacev1.FinalizeOperationRequest{}, &codespacev1.UpdateLogRequest{}, @@ -145,7 +123,7 @@ func TestManagerServiceRequestProtocolVersionFieldNumbers(t *testing.T) { } } -func TestManagerServiceDeclareAddressConflicts(t *testing.T) { +func TestManagerServiceDeclareAllowsSharedGatewayAddresses(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) client, cleanup := newManagerTestClient(t) defer cleanup() @@ -159,19 +137,15 @@ func TestManagerServiceDeclareAddressConflicts(t *testing.T) { _, err = client.DeclareManager(t.Context(), managerRequest(secondManager.ID, secondSecret, managerTestDeclaration("https://workspace.example.com", "other-ssh.example.com:22"))) - require.Error(t, err) - assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) - assert.Equal(t, "gateway_url_conflict", failureCategory(t, err)) + require.NoError(t, err) _, err = client.DeclareManager(t.Context(), managerRequest(secondManager.ID, secondSecret, managerTestDeclaration("https://other-gateway.example.com", "workspace.example.com:22"))) - require.Error(t, err) - assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) - assert.Equal(t, "gateway_ssh_addr_conflict", failureCategory(t, err)) + require.NoError(t, err) count, err := db.GetEngine(t.Context()).Where("manager_id = ?", secondManager.ID).Count(new(codespace_model.ManagerAddress)) require.NoError(t, err) - assert.EqualValues(t, 0, count) + assert.EqualValues(t, 2, count) } func TestManagerServiceDeclareAcceptsCookieScopeWarning(t *testing.T) { @@ -228,7 +202,7 @@ func TestManagerServiceFetchPayloadAndLease(t *testing.T) { require.NoError(t, err) require.Len(t, fetched.Msg.GetOperations(), 1) operation := fetched.Msg.GetOperations()[0] - assert.Equal(t, codespaceUUID, operation.GetCodespaceUuid()) + assert.Equal(t, codespaceUUID, operation.GetRuntimeUuid()) assert.EqualValues(t, 41, operation.GetOperationRversion()) assert.Positive(t, operation.GetLeaseValidForMilliseconds()) require.NotNil(t, operation.GetCreate()) @@ -241,14 +215,14 @@ func TestManagerServiceFetchPayloadAndLease(t *testing.T) { renewed, err := client.FetchOperations(t.Context(), managerRequest(manager.ID, secret, &codespacev1.FetchOperationsRequest{ ProtocolVersion: 1, ObservedOperations: []*codespacev1.ObservedOperation{{ - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, OperationRversion: 41, }}, })) require.NoError(t, err) assert.Empty(t, renewed.Msg.GetOperations()) require.Len(t, renewed.Msg.GetRenewedLeases(), 1) - assert.Equal(t, codespaceUUID, renewed.Msg.GetRenewedLeases()[0].GetCodespaceUuid()) + assert.Equal(t, codespaceUUID, renewed.Msg.GetRenewedLeases()[0].GetRuntimeUuid()) } func TestManagerServiceStructuredErrorDetails(t *testing.T) { @@ -280,7 +254,7 @@ func TestManagerServiceStructuredErrorDetails(t *testing.T) { }) written, err := client.UpdateLog(t.Context(), managerRequest(manager.ID, secret, &codespacev1.UpdateLogRequest{ ProtocolVersion: 1, - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, OperationRversion: 25, Lines: []*codespacev1.LogLine{{ TimestampUnixNano: time.Now().UnixNano(), @@ -292,7 +266,7 @@ func TestManagerServiceStructuredErrorDetails(t *testing.T) { _, err = client.UpdateLog(t.Context(), managerRequest(manager.ID, secret, &codespacev1.UpdateLogRequest{ ProtocolVersion: 1, - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, OperationRversion: 25, Offset: written.Msg.GetNextOffset() + 1, Lines: []*codespacev1.LogLine{{ @@ -324,7 +298,7 @@ func TestManagerServiceManagerOfflineCategory(t *testing.T) { _, err = client.RequestIdleStop(t.Context(), managerRequest(manager.ID, secret, &codespacev1.RequestIdleStopRequest{ ProtocolVersion: 1, - CodespaceUuid: "93939393-9393-4939-8939-939393939393", + RuntimeUuid: "93939393-9393-4939-8939-939393939393", ObservedSettings: &codespacev1.EffectiveCodespaceRuntimeSettings{ AutoStopEnabled: true, IdleTimeoutSeconds: 1800, @@ -341,7 +315,7 @@ func TestManagerServiceManagerOfflineCategory(t *testing.T) { _, err = client.ReportRuntimeMetadata(t.Context(), managerRequest(offlineManager.ID, offlineSecret, &codespacev1.ReportRuntimeMetadataRequest{ ProtocolVersion: 1, - CodespaceUuid: "93939393-9393-4939-8939-939393939393", + RuntimeUuid: "93939393-9393-4939-8939-939393939393", MetadataGeneration: 1, Metadata: managerTestRuntimeMetadata(1), })) @@ -351,7 +325,7 @@ func TestManagerServiceManagerOfflineCategory(t *testing.T) { _, err = client.RequestRuntimeAccess(t.Context(), managerRequest(offlineManager.ID, offlineSecret, &codespacev1.RequestRuntimeAccessRequest{ ProtocolVersion: 1, - CodespaceUuid: "93939393-9393-4939-8939-939393939393", + RuntimeUuid: "93939393-9393-4939-8939-939393939393", OperationRversion: 1, GitSshKey: &codespacev1.RuntimeGitSSHKey{PublicKey: []byte("not-a-key")}, })) @@ -381,7 +355,7 @@ func TestManagerServiceReportRuntimeMetadataVersionExhausted(t *testing.T) { _, err := client.ReportRuntimeMetadata(t.Context(), managerRequest(manager.ID, secret, &codespacev1.ReportRuntimeMetadataRequest{ ProtocolVersion: 1, - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, MetadataGeneration: math.MaxInt64, Metadata: managerTestRuntimeMetadata(31), })) @@ -389,7 +363,7 @@ func TestManagerServiceReportRuntimeMetadataVersionExhausted(t *testing.T) { _, err = client.ReportRuntimeMetadata(t.Context(), managerRequest(manager.ID, secret, &codespacev1.ReportRuntimeMetadataRequest{ ProtocolVersion: 1, - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, MetadataGeneration: math.MaxInt64 - 1, Metadata: managerTestRuntimeMetadata(31), })) @@ -408,7 +382,7 @@ func TestManagerServiceReportRuntimeMetadataDisabled(t *testing.T) { _, err := client.ReportRuntimeMetadata(t.Context(), managerRequest(manager.ID, secret, &codespacev1.ReportRuntimeMetadataRequest{ ProtocolVersion: 1, - CodespaceUuid: "93939393-9393-4939-8939-939393939393", + RuntimeUuid: "93939393-9393-4939-8939-939393939393", MetadataGeneration: 1, Metadata: managerTestRuntimeMetadata(1), })) diff --git a/routers/web/codespace/action.go b/routers/web/codespace/action.go index c4bbfa5eda43a..c04f3c478da74 100644 --- a/routers/web/codespace/action.go +++ b/routers/web/codespace/action.go @@ -22,7 +22,11 @@ func Stop(ctx *context.Context) { ctx.NotFound(nil) return } - returnPath := codespaceActionReturnPath(ctx.PathParam("uuid"), ctx.FormString("return_to"), codespaceDetailPath(ctx.PathParam("uuid"))) + codespaceID, ok := codespaceIDParam(ctx) + if !ok { + return + } + returnPath := codespaceActionReturnPath(codespaceID, ctx.FormString("return_to"), codespaceDetailPath(codespaceID)) _, err := codespace_service.StopCodespace(ctx, lifecycleActionOptions(ctx)) if err != nil { handleLifecycleActionError(ctx, "StopCodespace", err, returnPath) @@ -37,7 +41,11 @@ func Resume(ctx *context.Context) { ctx.NotFound(nil) return } - returnPath := codespaceActionReturnPath(ctx.PathParam("uuid"), ctx.FormString("return_to"), codespaceDetailPath(ctx.PathParam("uuid"))) + codespaceID, ok := codespaceIDParam(ctx) + if !ok { + return + } + returnPath := codespaceActionReturnPath(codespaceID, ctx.FormString("return_to"), codespaceDetailPath(codespaceID)) _, err := codespace_service.ResumeCodespace(ctx, lifecycleActionOptions(ctx)) if err != nil { handleLifecycleActionError(ctx, "ResumeCodespace", err, returnPath) @@ -52,7 +60,11 @@ func Delete(ctx *context.Context) { ctx.NotFound(nil) return } - returnPath := codespaceActionReturnPath(ctx.PathParam("uuid"), ctx.FormString("return_to"), codespaceListPath("", 1)) + codespaceID, ok := codespaceIDParam(ctx) + if !ok { + return + } + returnPath := codespaceActionReturnPath(codespaceID, ctx.FormString("return_to"), codespaceListPath("", 1)) _, err := codespace_service.DeleteCodespace(ctx, lifecycleActionOptions(ctx)) if err != nil { handleLifecycleActionError(ctx, "DeleteCodespace", err, returnPath) @@ -67,10 +79,14 @@ func Continue(ctx *context.Context) { ctx.NotFound(nil) return } - returnPath := codespaceActionReturnPath(ctx.PathParam("uuid"), ctx.FormString("return_to"), codespaceDetailPath(ctx.PathParam("uuid"))) + codespaceID, ok := codespaceIDParam(ctx) + if !ok { + return + } + returnPath := codespaceActionReturnPath(codespaceID, ctx.FormString("return_to"), codespaceDetailPath(codespaceID)) _, err := codespace_service.ContinueCodespace(ctx, codespace_service.ContinueCodespaceOptions{ - UserID: ctx.Doer.ID, - CodespaceUUID: ctx.PathParam("uuid"), + UserID: ctx.Doer.ID, + CodespaceID: codespaceID, }) if err != nil { handleInteractionError(ctx, "ContinueCodespace", err, returnPath) @@ -85,7 +101,11 @@ func AutoStop(ctx *context.Context) { ctx.NotFound(nil) return } - returnPath := codespaceActionReturnPath(ctx.PathParam("uuid"), ctx.FormString("return_to"), codespaceDetailPath(ctx.PathParam("uuid"))) + codespaceID, ok := codespaceIDParam(ctx) + if !ok { + return + } + returnPath := codespaceActionReturnPath(codespaceID, ctx.FormString("return_to"), codespaceDetailPath(codespaceID)) mode := ctx.FormString("mode") var timeout int64 switch mode { @@ -105,7 +125,7 @@ func AutoStop(ctx *context.Context) { } _, err := codespace_service.UpdateAutoStop(ctx, codespace_service.UpdateAutoStopOptions{ UserID: ctx.Doer.ID, - CodespaceUUID: ctx.PathParam("uuid"), + CodespaceID: codespaceID, Mode: mode, CustomTimeoutSeconds: timeout, }) @@ -157,17 +177,27 @@ func handleLifecycleActionError(ctx *context.Context, name string, err error, re } func lifecycleActionOptions(ctx *context.Context) codespace_service.LifecycleActionOptions { + codespaceID, _ := codespaceIDParam(ctx) return codespace_service.LifecycleActionOptions{ - UserID: ctx.Doer.ID, - CodespaceUUID: ctx.PathParam("uuid"), + UserID: ctx.Doer.ID, + CodespaceID: codespaceID, + } +} + +func codespaceIDParam(ctx *context.Context) (int64, bool) { + codespaceID, err := strconv.ParseInt(ctx.PathParam("codespace_id"), 10, 64) + if err != nil || codespaceID <= 0 { + ctx.NotFound(nil) + return 0, false } + return codespaceID, true } -func codespaceDetailPath(codespaceUUID string) string { - return "/-/codespaces/" + codespaceUUID +func codespaceDetailPath(codespaceID int64) string { + return "/-/codespaces/" + strconv.FormatInt(codespaceID, 10) } -func codespaceActionReturnPath(codespaceUUID, raw, fallback string) string { +func codespaceActionReturnPath(codespaceID int64, raw, fallback string) string { fallback = setting.AppSubURL + fallback if raw == "" { return fallback @@ -177,7 +207,7 @@ func codespaceActionReturnPath(codespaceUUID, raw, fallback string) string { return fallback } listPath := setting.AppSubURL + "/-/codespaces" - detailPath := setting.AppSubURL + codespaceDetailPath(codespaceUUID) + detailPath := setting.AppSubURL + codespaceDetailPath(codespaceID) if parsed.Path != listPath && parsed.Path != detailPath { return fallback } diff --git a/routers/web/codespace/action_test.go b/routers/web/codespace/action_test.go index 975ee58592f21..61d30e232801d 100644 --- a/routers/web/codespace/action_test.go +++ b/routers/web/codespace/action_test.go @@ -19,16 +19,16 @@ func TestCodespaceActionHelpers(t *testing.T) { input string want string }{ - {"", "/-/codespaces/uuid"}, + {"", "/-/codespaces/12"}, {"/-/codespaces?state=deleted", "/-/codespaces?state=deleted"}, - {"/-/codespaces/uuid?tab=logs", "/-/codespaces/uuid?tab=logs"}, - {"/-/codespaces/other", "/-/codespaces/uuid"}, - {"https://example.com/", "/-/codespaces/uuid"}, - {"//example.com/", "/-/codespaces/uuid"}, - {"relative", "/-/codespaces/uuid"}, + {"/-/codespaces/12?tab=logs", "/-/codespaces/12?tab=logs"}, + {"/-/codespaces/other", "/-/codespaces/12"}, + {"https://example.com/", "/-/codespaces/12"}, + {"//example.com/", "/-/codespaces/12"}, + {"relative", "/-/codespaces/12"}, } for _, test := range tests { - assert.Equal(t, test.want, codespaceActionReturnPath("uuid", test.input, codespaceDetailPath("uuid"))) + assert.Equal(t, test.want, codespaceActionReturnPath(12, test.input, codespaceDetailPath(12))) } assert.Equal(t, "/-/codespaces?owner=org&page=2", codespaceListPath("org", 2)) }) @@ -80,16 +80,16 @@ func TestCodespaceActionErrorResponses(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/test/action") - ctx.SetPathParam("uuid", "test") + ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/12/action") + ctx.SetPathParam("codespace_id", "12") if test.interaction { - handleInteractionError(ctx, "TestAction", test.err, "/-/codespaces/test") + handleInteractionError(ctx, "TestAction", test.err, "/-/codespaces/12") } else { - handleLifecycleActionError(ctx, "TestAction", test.err, "/-/codespaces/test") + handleLifecycleActionError(ctx, "TestAction", test.err, "/-/codespaces/12") } assert.Equal(t, test.status, resp.Code) if test.redirect { - assert.Equal(t, "/-/codespaces/test", resp.Header().Get("Location")) + assert.Equal(t, "/-/codespaces/12", resp.Header().Get("Location")) } }) } diff --git a/routers/web/codespace/create.go b/routers/web/codespace/create.go index 666cca92c872f..9bc705d8fbe91 100644 --- a/routers/web/codespace/create.go +++ b/routers/web/codespace/create.go @@ -98,7 +98,7 @@ func Create(ctx *context.Context) { handleCreateError(ctx, err) return } - ctx.Redirect(setting.AppSubURL+codespaceDetailPath(result.CodespaceUUID), http.StatusSeeOther) + ctx.Redirect(setting.AppSubURL+codespaceDetailPath(result.CodespaceID), http.StatusSeeOther) } func renderCreateConfirm(ctx *context.Context, status int, plan *codespace_service.CreateCodespacePlan, opts codespace_service.CreateCodespaceOptions, errorMessage template.HTML) { diff --git a/routers/web/codespace/log.go b/routers/web/codespace/log.go index 7e323e6c2714a..cd1a502f789b9 100644 --- a/routers/web/codespace/log.go +++ b/routers/web/codespace/log.go @@ -26,6 +26,10 @@ func Logs(ctx *context.Context) { ctx.NotFound(nil) return } + codespaceID, ok := codespaceIDParam(ctx) + if !ok { + return + } offset, ok := parseOptionalInt64Query(ctx, "offset", 0) if !ok { writeLogError(ctx, http.StatusBadRequest, "invalid_argument", 0) @@ -37,10 +41,10 @@ func Logs(ctx *context.Context) { return } result, err := codespace_service.ReadLog(ctx, codespace_service.ReadLogOptions{ - UserID: ctx.Doer.ID, - CodespaceUUID: ctx.PathParam("uuid"), - Offset: offset, - Limit: limit, + UserID: ctx.Doer.ID, + CodespaceID: codespaceID, + Offset: offset, + Limit: limit, }) if err != nil { var offsetErr *codespace_service.LogOffsetError @@ -74,11 +78,14 @@ func DownloadLogs(ctx *context.Context) { ctx.NotFound(nil) return } - codespaceUUID := ctx.PathParam("uuid") + codespaceID, ok := codespaceIDParam(ctx) + if !ok { + return + } result, err := codespace_service.ReadLog(ctx, codespace_service.ReadLogOptions{ - UserID: ctx.Doer.ID, - CodespaceUUID: codespaceUUID, - Limit: codespace_service.LogReadMaxBytes, + UserID: ctx.Doer.ID, + CodespaceID: codespaceID, + Limit: codespace_service.LogReadMaxBytes, }) if err != nil { handleDownloadLogError(ctx, err) @@ -87,7 +94,7 @@ func DownloadLogs(ctx *context.Context) { ctx.RespHeader().Set("Cache-Control", "no-store") ctx.RespHeader().Set("Content-Type", "text/plain; charset=utf-8") - ctx.RespHeader().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(codespaceUUID+".log")) + ctx.RespHeader().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment("codespace-"+strconv.FormatInt(codespaceID, 10)+".log")) ctx.Resp.WriteHeader(http.StatusOK) for { for _, line := range result.Lines { @@ -99,13 +106,13 @@ func DownloadLogs(ctx *context.Context) { return } result, err = codespace_service.ReadLog(ctx, codespace_service.ReadLogOptions{ - UserID: ctx.Doer.ID, - CodespaceUUID: codespaceUUID, - Offset: result.NextOffset, - Limit: codespace_service.LogReadMaxBytes, + UserID: ctx.Doer.ID, + CodespaceID: codespaceID, + Offset: result.NextOffset, + Limit: codespace_service.LogReadMaxBytes, }) if err != nil { - log.Error("Read Codespace log %s after response started: %v", codespaceUUID, err) + log.Error("Read Codespace log %d after response started: %v", codespaceID, err) return } } diff --git a/routers/web/codespace/log_test.go b/routers/web/codespace/log_test.go index 840c4dfd08585..e7ce69ed23e63 100644 --- a/routers/web/codespace/log_test.go +++ b/routers/web/codespace/log_test.go @@ -40,9 +40,9 @@ func TestLogsReturnsJSONPage(t *testing.T) { }) require.NoError(t, err) - ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs?offset=0&limit=40") + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs?offset=0&limit=40") contexttest.LoadUser(t, ctx, 1) - ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) Logs(ctx) require.Equal(t, http.StatusOK, resp.Code) @@ -62,10 +62,9 @@ func TestLogsReturnsJSONPage(t *testing.T) { func TestLogsRejectsInvalidArgument(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) - codespaceUUID := "93909090-9090-4090-8090-909090909090" - ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs?offset=bad") + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/1/logs?offset=bad") contexttest.LoadUser(t, ctx, 1) - ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("codespace_id", "1") Logs(ctx) require.Equal(t, http.StatusBadRequest, resp.Code) @@ -80,16 +79,16 @@ func TestLogsHideOtherCreatorCodespace(t *testing.T) { codespaceUUID := "97909090-9090-4090-8090-909090909090" insertWebLogCodespace(t, manager.ID, codespaceUUID, 98) - ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs") + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs") contexttest.LoadUser(t, ctx, 2) - ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) Logs(ctx) require.Equal(t, http.StatusNotFound, resp.Code) assert.Equal(t, "codespace_not_found", decodeLogError(t, resp.Body.Bytes()).Category) - ctx, resp = contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs/download") + ctx, resp = contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs/download") contexttest.LoadUser(t, ctx, 2) - ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) DownloadLogs(ctx) require.Equal(t, http.StatusNotFound, resp.Code) } @@ -111,9 +110,9 @@ func TestLogsReportsOffsetConflict(t *testing.T) { }) require.NoError(t, err) - ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs?offset="+strconv.FormatInt(result.NextOffset+1, 10)) + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs?offset="+strconv.FormatInt(result.NextOffset+1, 10)) contexttest.LoadUser(t, ctx, 1) - ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) Logs(ctx) require.Equal(t, http.StatusConflict, resp.Code) @@ -139,21 +138,21 @@ func TestLogPageAndDownloadUseStoredContent(t *testing.T) { }) require.NoError(t, err) - detailCtx, detailResp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID, contexttest.MockContextOption{Render: templates.PageRenderer()}) + detailCtx, detailResp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10), contexttest.MockContextOption{Render: templates.PageRenderer()}) contexttest.LoadUser(t, detailCtx, 1) - detailCtx.SetPathParam("uuid", codespaceUUID) + detailCtx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) Detail(detailCtx) require.Equal(t, http.StatusOK, detailResp.Code) assert.Contains(t, detailResp.Body.String(), "data-log-next-offset=\"0\"") - assert.Contains(t, detailResp.Body.String(), "/-/codespaces/"+codespaceUUID+"/logs/download") + assert.Contains(t, detailResp.Body.String(), "/-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs/download") - downloadCtx, downloadResp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs/download") + downloadCtx, downloadResp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs/download") contexttest.LoadUser(t, downloadCtx, 1) - downloadCtx.SetPathParam("uuid", codespaceUUID) + downloadCtx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) DownloadLogs(downloadCtx) require.Equal(t, http.StatusOK, downloadResp.Code) assert.Equal(t, "no-store", downloadResp.Header().Get("Cache-Control")) - assert.Contains(t, downloadResp.Header().Get("Content-Disposition"), codespaceUUID+".log") + assert.Contains(t, downloadResp.Header().Get("Content-Disposition"), "codespace-"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+".log") assert.Contains(t, downloadResp.Body.String(), "download same\n") } @@ -178,9 +177,9 @@ func TestDownloadLogsReadsAllPages(t *testing.T) { }) require.NoError(t, err) - ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"/logs/download") + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs/download") contexttest.LoadUser(t, ctx, 1) - ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) DownloadLogs(ctx) require.Equal(t, http.StatusOK, resp.Code) diff --git a/routers/web/codespace/open.go b/routers/web/codespace/open.go index d95c1a8f4a98b..03da24d75b801 100644 --- a/routers/web/codespace/open.go +++ b/routers/web/codespace/open.go @@ -31,10 +31,14 @@ func openEndpoint(ctx *context.Context, endpointID string) { ctx.NotFound(nil) return } + codespaceID, ok := codespaceIDParam(ctx) + if !ok { + return + } result, err := codespace_service.OpenEndpoint(ctx, codespace_service.OpenEndpointOptions{ - UserID: ctx.Doer.ID, - CodespaceUUID: ctx.PathParam("uuid"), - EndpointID: endpointID, + UserID: ctx.Doer.ID, + CodespaceID: codespaceID, + EndpointID: endpointID, }) if err != nil { if errors.Is(err, codespace_service.ErrOpenEndpointNotFound) { @@ -43,7 +47,7 @@ func openEndpoint(ctx *context.Context, endpointID string) { } if errors.Is(err, codespace_service.ErrOpenEndpointUnavailable) { ctx.Flash.Warning(ctx.Tr("codespace.open.unavailable")) - ctx.Redirect(codespaceDetailPath(ctx.PathParam("uuid")), http.StatusSeeOther) + ctx.Redirect(codespaceDetailPath(codespaceID), http.StatusSeeOther) return } ctx.ServerError("OpenEndpoint", err) diff --git a/routers/web/codespace/open_test.go b/routers/web/codespace/open_test.go index 4f72e42edf11e..f3c125c835a24 100644 --- a/routers/web/codespace/open_test.go +++ b/routers/web/codespace/open_test.go @@ -6,6 +6,7 @@ package codespace import ( "net/http" "net/url" + "strconv" "testing" "time" @@ -32,9 +33,9 @@ func TestOpenEndpointRedirectsWithOneTimeCode(t *testing.T) { MetadataGeneration: 1, })) - ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/"+codespaceUUID+"/open/app-3000") + ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/open/app-3000") contexttest.LoadUser(t, ctx, 1) - ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) ctx.SetPathParam("endpoint_id", "app-3000") OpenEndpoint(ctx) @@ -63,9 +64,9 @@ func TestOpenEndpointPublicRedirectsWithoutCode(t *testing.T) { MetadataGeneration: 1, })) - ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/"+codespaceUUID+"/open/app-3000") + ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/open/app-3000") contexttest.LoadUser(t, ctx, 1) - ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) ctx.SetPathParam("endpoint_id", "app-3000") OpenEndpoint(ctx) @@ -88,9 +89,9 @@ func TestOpenEndpointHidesOtherCreatorCodespace(t *testing.T) { codespaceUUID := "99999999-9999-4999-8999-999999999999" insertWebOpenCodespace(t, manager.ID, codespaceUUID, 93) - ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/"+codespaceUUID+"/open") + ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/open") contexttest.LoadUser(t, ctx, 2) - ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) Open(ctx) require.Equal(t, http.StatusNotFound, resp.Code) diff --git a/routers/web/codespace/settings.go b/routers/web/codespace/settings.go index 260e7d8e7b518..7f41d105fb638 100644 --- a/routers/web/codespace/settings.go +++ b/routers/web/codespace/settings.go @@ -23,7 +23,7 @@ const ( tplUserDevContainerTemplates templates.TplName = "codespace/user_devcontainer_templates" ) -// AdminManagers renders site-wide Manager and global registration token settings. +// AdminManagers renders site-wide Manager settings. func AdminManagers(ctx *context.Context) { renderManagerSettings(ctx, managerSettingsRenderOptions{ Scope: codespace_service.ManagerSettingsScopeSite, @@ -51,9 +51,9 @@ func AdminManagerDelete(ctx *context.Context) { }) } -// AdminManagersResetRegistrationToken resets the global Manager registration token. -func AdminManagersResetRegistrationToken(ctx *context.Context) { - handleManagerSettingsResetRegistrationToken(ctx, managerSettingsRenderOptions{ +// AdminManagersCreateManager creates a site-wide Manager identity. +func AdminManagersCreateManager(ctx *context.Context) { + handleManagerSettingsCreateManager(ctx, managerSettingsRenderOptions{ Scope: codespace_service.ManagerSettingsScopeSite, ActionBase: setting.AppSubURL + "/-/admin/codespaces/managers", Template: tplAdminCodespaceManagers, @@ -91,7 +91,7 @@ func AdminDevContainerTemplateDelete(ctx *context.Context) { }) } -// UserSettings renders current user's Manager and registration token settings. +// UserSettings renders current user's Manager settings. func UserSettings(ctx *context.Context) { renderManagerSettings(ctx, managerSettingsRenderOptions{ Scope: codespace_service.ManagerSettingsScopeUser, @@ -122,9 +122,9 @@ func UserManagerDelete(ctx *context.Context) { }) } -// UserSettingsResetRegistrationToken resets the current user's Manager registration token. -func UserSettingsResetRegistrationToken(ctx *context.Context) { - handleManagerSettingsResetRegistrationToken(ctx, managerSettingsRenderOptions{ +// UserSettingsCreateManager creates a Manager identity owned by the current user. +func UserSettingsCreateManager(ctx *context.Context) { + handleManagerSettingsCreateManager(ctx, managerSettingsRenderOptions{ Scope: codespace_service.ManagerSettingsScopeUser, UserID: ctx.Doer.ID, ActionBase: setting.AppSubURL + "/user/settings/codespaces/managers", @@ -228,13 +228,20 @@ func handleDevContainerTemplateActionError(ctx *context.Context, redirectTo stri } func renderManagerSettings(ctx *context.Context, opts managerSettingsRenderOptions) { + if !populateManagerSettingsData(ctx, opts) { + return + } + ctx.HTML(http.StatusOK, opts.Template) +} + +func populateManagerSettingsData(ctx *context.Context, opts managerSettingsRenderOptions) bool { settingsView, err := codespace_service.ListManagerSettings(ctx, codespace_service.ManagerSettingsOptions{ Scope: opts.Scope, UserID: opts.UserID, }) if err != nil { ctx.ServerError("ListManagerSettings", err) - return + return false } ctx.Data["Title"] = "Codespaces" ctx.Data[opts.PageFlag] = true @@ -251,7 +258,7 @@ func renderManagerSettings(ctx *context.Context, opts managerSettingsRenderOptio }) if err != nil { ctx.ServerError("ListUnassignedCodespaces", err) - return + return false } ctx.Data["Codespaces"] = unassigned.Rows ctx.Data["CodespaceTotal"] = unassigned.Total @@ -259,7 +266,7 @@ func renderManagerSettings(ctx *context.Context, opts managerSettingsRenderOptio ctx.Data["CodespaceActionBase"] = opts.ActionBase + "/unassigned" ctx.Data["Page"] = context.NewPagination(unassigned.Total, setting.UI.Admin.UserPagingNum, page, 5) } - ctx.HTML(http.StatusOK, opts.Template) + return true } func renderManagerDetail(ctx *context.Context, opts managerSettingsRenderOptions) { @@ -305,8 +312,8 @@ func handleManagerDelete(ctx *context.Context, opts managerSettingsRenderOptions ctx.JSONRedirect(opts.ActionBase) } -func handleManagerSettingsResetRegistrationToken(ctx *context.Context, opts managerSettingsRenderOptions) { - _, err := codespace_service.ResetRegistrationToken(ctx, codespace_service.ManagerSettingsOptions{ +func handleManagerSettingsCreateManager(ctx *context.Context, opts managerSettingsRenderOptions) { + result, err := codespace_service.CreateManager(ctx, codespace_service.ManagerSettingsOptions{ Scope: opts.Scope, UserID: opts.UserID, }) @@ -314,8 +321,12 @@ func handleManagerSettingsResetRegistrationToken(ctx *context.Context, opts mana handleManagerSettingsActionError(ctx, opts.ActionBase, err) return } - ctx.Flash.Success(ctx.Tr("codespace.registration_token_reset")) - ctx.JSONRedirect(opts.ActionBase) + if !populateManagerSettingsData(ctx, opts) { + return + } + ctx.Data["NewManagerID"] = result.ManagerID + ctx.Data["NewManagerSecret"] = result.Secret + ctx.HTML(http.StatusOK, opts.Template) } func handleManagerSettingsActionError(ctx *context.Context, redirectTo string, err error) { diff --git a/routers/web/codespace/view.go b/routers/web/codespace/view.go index d1c859836589b..1abcea74e27f5 100644 --- a/routers/web/codespace/view.go +++ b/routers/web/codespace/view.go @@ -160,9 +160,13 @@ func loadCreatorDetail(ctx *context.Context) (*codespace_service.CreatorCodespac ctx.NotFound(nil) return nil, false } + codespaceID, ok := codespaceIDParam(ctx) + if !ok { + return nil, false + } view, err := codespace_service.GetCreatorCodespace(ctx, codespace_service.CreatorDetailOptions{ - UserID: ctx.Doer.ID, - CodespaceUUID: ctx.PathParam("uuid"), + UserID: ctx.Doer.ID, + CodespaceID: codespaceID, }) if err != nil { switch { diff --git a/routers/web/codespace/view_test.go b/routers/web/codespace/view_test.go index 3a94610ec036e..b10e076dd7100 100644 --- a/routers/web/codespace/view_test.go +++ b/routers/web/codespace/view_test.go @@ -5,6 +5,7 @@ package codespace import ( "net/http" + "strconv" "testing" codespace_model "gitea.dev/models/codespace" @@ -38,7 +39,7 @@ func TestListRendersCreatorCodespaces(t *testing.T) { require.True(t, ok) require.Len(t, rows, 1) assert.Equal(t, codespaceUUID, rows[0].UUID) - assert.Contains(t, resp.Body.String(), codespaceUUID) + assert.Contains(t, resp.Body.String(), rows[0].ShortUUID) assert.Contains(t, resp.Body.String(), "context-user-switch") assert.NotNil(t, ctx.Data["Page"]) } @@ -73,10 +74,11 @@ func TestDetailRendersCreatorCodespaceNoStore(t *testing.T) { OperationType: codespace_model.OperationCreate, OperationStatus: codespace_model.OperationStatusQueued, }) + codespaceID := webCodespaceIDByUUID(t, codespaceUUID) - ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID, contexttest.MockContextOption{Render: templates.PageRenderer()}) + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(codespaceID, 10), contexttest.MockContextOption{Render: templates.PageRenderer()}) contexttest.LoadUser(t, ctx, 1) - ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("codespace_id", strconv.FormatInt(codespaceID, 10)) Detail(ctx) require.Equal(t, http.StatusOK, resp.Code) @@ -98,10 +100,11 @@ func TestDetailPreservesExplicitOverviewTab(t *testing.T) { OperationType: codespace_model.OperationCreate, OperationStatus: codespace_model.OperationStatusQueued, }) + codespaceID := webCodespaceIDByUUID(t, codespaceUUID) - ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"?tab=overview", contexttest.MockContextOption{Render: templates.PageRenderer()}) + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(codespaceID, 10)+"?tab=overview", contexttest.MockContextOption{Render: templates.PageRenderer()}) contexttest.LoadUser(t, ctx, 1) - ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("codespace_id", strconv.FormatInt(codespaceID, 10)) Detail(ctx) require.Equal(t, http.StatusOK, resp.Code) @@ -116,15 +119,16 @@ func TestDetailOpensGatewayRecoveryModal(t *testing.T) { manager := insertWebOpenManager(t, "https://gateway.example.com") codespaceUUID := "27272727-2727-4727-8727-272727272727" insertWebOpenCodespace(t, manager.ID, codespaceUUID, 94) + codespaceID := webCodespaceIDByUUID(t, codespaceUUID) require.NoError(t, codespace_service.ReportRuntimeMetadata(t.Context(), manager, codespace_service.ReportRuntimeMetadataOptions{ CodespaceUUID: codespaceUUID, Metadata: webOpenRuntimeMetadata(t, 94, []map[string]any{{"endpoint_id": "app-3000", "label": "App", "public": false}}), MetadataGeneration: 1, })) - ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID+"?open_endpoint=app-3000", contexttest.MockContextOption{Render: templates.PageRenderer()}) + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(codespaceID, 10)+"?open_endpoint=app-3000", contexttest.MockContextOption{Render: templates.PageRenderer()}) contexttest.LoadUser(t, ctx, 1) - ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("codespace_id", strconv.FormatInt(codespaceID, 10)) Detail(ctx) require.Equal(t, http.StatusOK, resp.Code) @@ -132,7 +136,7 @@ func TestDetailOpensGatewayRecoveryModal(t *testing.T) { require.True(t, ok) assert.Equal(t, "App", modal.Label) assert.Equal(t, "codespace.authenticated_endpoint", modal.Access) - assert.Equal(t, "/-/codespaces/"+codespaceUUID+"/open/app-3000", modal.OpenPath) + assert.Equal(t, "/-/codespaces/"+strconv.FormatInt(codespaceID, 10)+"/open/app-3000", modal.OpenPath) } func TestDetailRejectsOtherCreator(t *testing.T) { @@ -143,10 +147,11 @@ func TestDetailRejectsOtherCreator(t *testing.T) { UUID: codespaceUUID, Status: codespace_model.StatusStopped, }) + codespaceID := webCodespaceIDByUUID(t, codespaceUUID) - ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+codespaceUUID) + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(codespaceID, 10)) contexttest.LoadUser(t, ctx, 2) - ctx.SetPathParam("uuid", codespaceUUID) + ctx.SetPathParam("codespace_id", strconv.FormatInt(codespaceID, 10)) Detail(ctx) require.Equal(t, http.StatusNotFound, resp.Code) @@ -165,3 +170,12 @@ func insertWebViewCodespace(t *testing.T, codespace *codespace_model.Codespace) codespace.UpdatedUnix = 1 require.NoError(t, db.Insert(t.Context(), codespace)) } + +func webCodespaceIDByUUID(t *testing.T, codespaceUUID string) int64 { + t.Helper() + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(t.Context()).Where("uuid = ?", codespaceUUID).Get(codespace) + require.NoError(t, err) + require.True(t, has) + return codespace.ID +} diff --git a/routers/web/web.go b/routers/web/web.go index 53f5ed2b4ec11..5f0007bb3b070 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -548,7 +548,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/-/web-theme/list", misc.WebThemeList) m.Post("/-/web-theme/apply", optSignIn, misc.WebThemeApply) m.Get("/-/codespaces", reqSignIn, web_codespace.List) - m.Group("/-/codespaces/{uuid}", func() { + m.Group("/-/codespaces/{codespace_id}", func() { m.Get("", web_codespace.Detail) m.Get("/state", web_codespace.State) m.Get("/logs", web_codespace.Logs) @@ -745,7 +745,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }, actions.MustEnableActions) m.Get("/codespaces/managers", web_codespace.UserSettings) - m.Post("/codespaces/managers/reset_registration_token", web_codespace.UserSettingsResetRegistrationToken) + m.Post("/codespaces/managers/create", web_codespace.UserSettingsCreateManager) m.Get("/codespaces/managers/{manager_id}", web_codespace.UserManager) m.Post("/codespaces/managers/{manager_id}/delete", web_codespace.UserManagerDelete) m.Combo("/codespaces/dev-container-templates").Get(web_codespace.UserDevContainerTemplates).Post(web_codespace.UserDevContainerTemplatePost) @@ -872,7 +872,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("/codespaces", func() { m.Get("/managers", web_codespace.AdminManagers) - m.Post("/managers/reset_registration_token", web_codespace.AdminManagersResetRegistrationToken) + m.Post("/managers/create", web_codespace.AdminManagersCreateManager) m.Get("/managers/{manager_id}", web_codespace.AdminManager) m.Post("/managers/{manager_id}/delete", web_codespace.AdminManagerDelete) m.Combo("/dev-container-templates").Get(web_codespace.AdminDevContainerTemplates).Post(web_codespace.AdminDevContainerTemplatePost) diff --git a/services/codespace/create.go b/services/codespace/create.go index 8631cce8c2667..d7c45dfca41a9 100644 --- a/services/codespace/create.go +++ b/services/codespace/create.go @@ -57,7 +57,7 @@ type CreateCodespaceOptions struct { // CreateCodespaceResult contains the new object identity and initial state. type CreateCodespaceResult struct { - CodespaceUUID string + CodespaceID int64 Status string EnvironmentTag string } @@ -254,7 +254,7 @@ func CreateCodespace(ctx context.Context, opts CreateCodespaceOptions) (*CreateC return err } result = &CreateCodespaceResult{ - CodespaceUUID: codespace.UUID, + CodespaceID: codespace.ID, Status: codespace.Status, EnvironmentTag: codespace.EnvironmentTag, } @@ -728,9 +728,7 @@ func ensurePermissionAuthorization(ctx context.Context, userID, sourceRepoID int func newCreateCodespaceRow(userID, repoID int64, environmentTag string, sourceRef *createSourceRef, devContainer *createDevContainerPlan, authorizationID int64) *codespace_model.Codespace { now := time.Now().Unix() - codespaceUUID := codespace_model.NewUUID() codespace := &codespace_model.Codespace{ - UUID: codespaceUUID, UserID: userID, RepoID: repoID, RefType: sourceRef.Type, diff --git a/services/codespace/create_test.go b/services/codespace/create_test.go index a0a5475c3a011..d952d5e78e205 100644 --- a/services/codespace/create_test.go +++ b/services/codespace/create_test.go @@ -42,7 +42,7 @@ func TestCreateCodespaceQueuesCreateWhenManagerMatches(t *testing.T) { assert.Equal(t, codespace_model.StatusCreating, result.Status) assert.Equal(t, "default", result.EnvironmentTag) - row := loadServiceCodespace(t, result.CodespaceUUID) + row := loadServiceCodespaceByID(t, result.CodespaceID) assert.Equal(t, user.ID, row.UserID) assert.Equal(t, repo.ID, row.RepoID) assert.Equal(t, "branch", row.RefType) @@ -74,7 +74,7 @@ func TestCreateCodespaceUsesCreatorManagerForOrganizationRepository(t *testing.T require.NoError(t, err) assert.Equal(t, codespace_model.StatusCreating, result.Status) - row := loadServiceCodespace(t, result.CodespaceUUID) + row := loadServiceCodespaceByID(t, result.CodespaceID) assert.Equal(t, user.ID, row.UserID) assert.Equal(t, repo.ID, row.RepoID) } @@ -179,7 +179,7 @@ func TestCreateCodespacePersistsPullRefAndValidatesGitProtocol(t *testing.T) { assert.Equal(t, "user2/repo1", plan.PullRequest.HeadRepoFullName) assert.Equal(t, "branch2", plan.PullRequest.HeadBranch) - row := loadServiceCodespace(t, result.CodespaceUUID) + row := loadServiceCodespaceByID(t, result.CodespaceID) assert.Equal(t, "pull", row.RefType) assert.Equal(t, "refs/pull/3/head", row.RefName) assert.NotEmpty(t, row.CommitSHA) @@ -365,7 +365,7 @@ CONFIG } first, err := CreateCodespace(t.Context(), opts) require.NoError(t, err) - firstCodespace := loadServiceCodespace(t, first.CodespaceUUID) + firstCodespace := loadServiceCodespaceByID(t, first.CodespaceID) assert.Equal(t, codespace_model.DevContainerSourceRepository, firstCodespace.DevContainerSource) assert.Equal(t, devContainerPrimaryPath, firstCodespace.DevContainerPath) assert.Empty(t, firstCodespace.DevContainerContent) @@ -394,13 +394,13 @@ CONFIG second, err := CreateCodespace(t.Context(), opts) require.NoError(t, err) - assert.Equal(t, authorization.ID, loadServiceCodespace(t, second.CodespaceUUID).PermissionAuthorizationID) + assert.Equal(t, authorization.ID, loadServiceCodespaceByID(t, second.CodespaceID).PermissionAuthorizationID) require.NotNil(t, codeRule) require.NoError(t, ReducePermissionRepository(t.Context(), user.ID, authorization.ID, codeRule.TargetRepoID, codeRule.UnitType, perm.AccessModeNone)) third, err := CreateCodespace(t.Context(), opts) require.NoError(t, err) - assert.NotEqual(t, authorization.ID, loadServiceCodespace(t, third.CodespaceUUID).PermissionAuthorizationID) + assert.NotEqual(t, authorization.ID, loadServiceCodespaceByID(t, third.CodespaceID).PermissionAuthorizationID) } func createConfirmedCodespace(t *testing.T, opts CreateCodespaceOptions) (*CreateCodespaceResult, error) { diff --git a/services/codespace/disabled_test.go b/services/codespace/disabled_test.go index 3883b1e2fa08f..270d838c0ea9e 100644 --- a/services/codespace/disabled_test.go +++ b/services/codespace/disabled_test.go @@ -38,9 +38,9 @@ func TestGatewayAndRuntimeRPCsRejectDisabledCodespace(t *testing.T) { {"endpoint_id": "private-api", "label": "API", "public": false}, }))) issued, err := openEndpoint(t.Context(), OpenEndpointOptions{ - UserID: 1, - CodespaceUUID: runningUUID, - EndpointID: "private-api", + UserID: 1, + CodespaceID: codespaceIDByUUID(t, runningUUID), + EndpointID: "private-api", }) require.NoError(t, err) assert.EqualValues(t, 6, loadServiceCodespace(t, runningUUID).InteractionGeneration) @@ -56,7 +56,7 @@ func TestGatewayAndRuntimeRPCsRejectDisabledCodespace(t *testing.T) { sessionResult, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ Session: &codespacev1.RevalidateGatewaySessionRequest_Endpoint{Endpoint: &codespacev1.EndpointSessionBinding{ - UserId: 1, CodespaceUuid: runningUUID, EndpointId: "private-api", + UserId: 1, RuntimeUuid: runningUUID, EndpointId: "private-api", }}, }) require.NoError(t, err) @@ -70,9 +70,9 @@ func TestGatewayAndRuntimeRPCsRejectDisabledCodespace(t *testing.T) { assert.Equal(t, SSHAuthDeniedStateUnavailable, sshResult.GetDenied().GetCategory()) _, err = OpenEndpoint(t.Context(), OpenEndpointOptions{ - UserID: 1, - CodespaceUUID: runningUUID, - EndpointID: "private-api", + UserID: 1, + CodespaceID: codespaceIDByUUID(t, runningUUID), + EndpointID: "private-api", }) require.ErrorIs(t, err, ErrOpenEndpointUnavailable) @@ -136,19 +136,13 @@ func TestDisabledCodespaceRejectsStartupEntrypoints(t *testing.T) { OperationRVersion: 94, InteractionGeneration: 1, }) - token, err := GetOrCreateRegistrationToken(t.Context(), ManagerSettingsOptions{Scope: ManagerSettingsScopeSite}) - require.NoError(t, err) - t.Cleanup(test.MockVariableValue(&setting.Codespace.Enabled, false)) - _, err = ResumeCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: stoppedUUID}) + _, err := ResumeCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, stoppedUUID)}) require.ErrorIs(t, err, ErrLifecycleActionStateUnavailable) assert.Empty(t, loadServiceCodespace(t, stoppedUUID).OperationType) - _, err = ContinueCodespace(t.Context(), ContinueCodespaceOptions{UserID: 1, CodespaceUUID: runningUUID}) + _, err = ContinueCodespace(t.Context(), ContinueCodespaceOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, runningUUID)}) require.ErrorIs(t, err, ErrInteractionStateUnavailable) assert.EqualValues(t, 1, loadServiceCodespace(t, runningUUID).InteractionGeneration) - - _, _, err = RegisterManager(t.Context(), token) - require.ErrorIs(t, err, ErrRegistrationStateUnavailable) } diff --git a/services/codespace/fetch.go b/services/codespace/fetch.go index 4a5b1bc439e10..f0ef812261279 100644 --- a/services/codespace/fetch.go +++ b/services/codespace/fetch.go @@ -148,16 +148,16 @@ func validateFetchOptions(opts FetchOperationsOptions) error { if observed == nil { return errors.New("observed operation is required") } - if err := codespace_model.ValidateUUID(observed.GetCodespaceUuid()); err != nil { + if err := codespace_model.ValidateUUID(observed.GetRuntimeUuid()); err != nil { return err } if observed.GetOperationRversion() <= 0 { return errors.New("observed operation_rversion must be positive") } - if _, ok := seen[observed.GetCodespaceUuid()]; ok { + if _, ok := seen[observed.GetRuntimeUuid()]; ok { return errors.New("observed_operations contains duplicate codespace uuid") } - seen[observed.GetCodespaceUuid()] = struct{}{} + seen[observed.GetRuntimeUuid()] = struct{}{} } for _, acceptedType := range opts.AcceptedOperationTypes { if acceptedType != codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_CREATE && acceptedType != codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_RESUME { @@ -238,9 +238,9 @@ func isManagerOffline(manager *codespace_model.Manager) bool { func validateObservedOperationHistory(ctx context.Context, managerID int64, observed []*codespacev1.ObservedOperation) (map[string]int64, error) { observedVersions := make(map[string]int64, len(observed)) for _, item := range observed { - observedVersions[item.GetCodespaceUuid()] = item.GetOperationRversion() + observedVersions[item.GetRuntimeUuid()] = item.GetOperationRversion() codespace := new(codespace_model.Codespace) - has, err := db.GetEngine(ctx).Where("uuid = ?", item.GetCodespaceUuid()).Get(codespace) + has, err := db.GetEngine(ctx).Where("uuid = ?", item.GetRuntimeUuid()).Get(codespace) if err != nil { return nil, err } @@ -264,7 +264,7 @@ func appendRunningOperations(ctx context.Context, managerID int64, observedVersi } grantTime := time.Now() for _, row := range rows { - err := globallock.LockAndDo(ctx, codespaceStateLockKey(row.UUID), func(ctx context.Context) error { + err := globallock.LockAndDo(ctx, codespaceRowLockKey(row.ID), func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error { codespace := new(codespace_model.Codespace) has, err := db.GetEngine(ctx).ID(row.ID).Get(codespace) @@ -301,7 +301,7 @@ func appendRunningOperations(ctx context.Context, managerID int64, observedVersi } if observedVersion == codespace.OperationRVersion { result.RenewedLeases = append(result.RenewedLeases, &codespacev1.RenewedOperationLease{ - CodespaceUuid: codespace.UUID, + RuntimeUuid: codespace.UUID, OperationRversion: codespace.OperationRVersion, LeaseValidForMilliseconds: leaseMillis, }) @@ -355,7 +355,7 @@ func claimQueuedOperations(ctx context.Context, managerID, managerUserID int64, } if isQueuedExpired(candidate, grantTime) { var summary *internalStateSummary - err := globallock.LockAndDo(ctx, codespaceStateLockKey(candidate.UUID), func(ctx context.Context) error { + err := globallock.LockAndDo(ctx, codespaceRowLockKey(candidate.ID), func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error { current := new(codespace_model.Codespace) has, err := db.GetEngine(ctx).ID(candidate.ID).Get(current) @@ -510,7 +510,8 @@ func ceilUnix(t time.Time) int64 { func buildOperationPayload(ctx context.Context, codespace *codespace_model.Codespace, leaseMillis int64) (*codespacev1.OperationPayload, error) { payload := &codespacev1.OperationPayload{ OperationRversion: codespace.OperationRVersion, - CodespaceUuid: codespace.UUID, + CodespaceId: codespace.ID, + RuntimeUuid: codespace.UUID, LogOffset: codespace.LogSize, LeaseValidForMilliseconds: leaseMillis, } @@ -538,7 +539,8 @@ func buildOperationPayload(ctx context.Context, codespace *codespace_model.Codes func buildAbortOperationPayload(codespace *codespace_model.Codespace) *codespacev1.OperationPayload { payload := &codespacev1.OperationPayload{ OperationRversion: codespace.OperationRVersion, - CodespaceUuid: codespace.UUID, + CodespaceId: codespace.ID, + RuntimeUuid: codespace.UUID, LogOffset: codespace.LogSize, } if codespace.OperationType == codespace_model.OperationResume { diff --git a/services/codespace/fetch_test.go b/services/codespace/fetch_test.go index 564795cc0a438..c9c11c11c2e07 100644 --- a/services/codespace/fetch_test.go +++ b/services/codespace/fetch_test.go @@ -44,7 +44,7 @@ func TestFetchOperationsClaimsCreate(t *testing.T) { require.NoError(t, err) require.Len(t, result.Operations, 1) operation := result.Operations[0] - assert.Equal(t, codespaceUUID, operation.GetCodespaceUuid()) + assert.Equal(t, codespaceUUID, operation.GetRuntimeUuid()) assert.EqualValues(t, 31, operation.GetOperationRversion()) assert.EqualValues(t, setting.Codespace.OperationLeaseTimeout/time.Millisecond, operation.GetLeaseValidForMilliseconds()) create := operation.GetCreate() @@ -149,7 +149,7 @@ func TestFetchOperationsResumeUsesExistingManagerBinding(t *testing.T) { }) require.NoError(t, err) require.Len(t, result.Operations, 1) - assert.Equal(t, codespaceUUID, result.Operations[0].GetCodespaceUuid()) + assert.Equal(t, codespaceUUID, result.Operations[0].GetRuntimeUuid()) assert.NotNil(t, result.Operations[0].GetResume()) } @@ -352,8 +352,8 @@ func TestFetchOperationsDisabledDrainsWithoutClaimingStartup(t *testing.T) { AcceptedCreateTags: []string{"default"}, CleanupCapacityAvailable: 1, ObservedOperations: []*codespacev1.ObservedOperation{ - {CodespaceUuid: runningCreateUUID, OperationRversion: 51}, - {CodespaceUuid: runningStopUUID, OperationRversion: 52}, + {RuntimeUuid: runningCreateUUID, OperationRversion: 51}, + {RuntimeUuid: runningStopUUID, OperationRversion: 52}, }, }) require.NoError(t, err) @@ -362,7 +362,7 @@ func TestFetchOperationsDisabledDrainsWithoutClaimingStartup(t *testing.T) { assert.Zero(t, result.Operations[0].GetLeaseValidForMilliseconds()) assert.NotNil(t, result.Operations[1].GetStop()) require.Len(t, result.RenewedLeases, 1) - assert.Equal(t, runningStopUUID, result.RenewedLeases[0].GetCodespaceUuid()) + assert.Equal(t, runningStopUUID, result.RenewedLeases[0].GetRuntimeUuid()) assert.Equal(t, originalCreateDeadline, loadServiceCodespace(t, runningCreateUUID).OperationDeadlineUnix) assert.Zero(t, loadServiceCodespace(t, queuedCreateUUID).ManagerID) @@ -478,14 +478,14 @@ func TestFetchOperationsRenewsObservedOperation(t *testing.T) { result, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ ObservedOperations: []*codespacev1.ObservedOperation{{ - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, OperationRversion: 32, }}, }) require.NoError(t, err) assert.Empty(t, result.Operations) require.Len(t, result.RenewedLeases, 1) - assert.Equal(t, codespaceUUID, result.RenewedLeases[0].GetCodespaceUuid()) + assert.Equal(t, codespaceUUID, result.RenewedLeases[0].GetRuntimeUuid()) assert.EqualValues(t, 32, result.RenewedLeases[0].GetOperationRversion()) assert.EqualValues(t, setting.Codespace.OperationLeaseTimeout/time.Millisecond, result.RenewedLeases[0].GetLeaseValidForMilliseconds()) assert.Greater(t, loadServiceCodespace(t, codespaceUUID).OperationDeadlineUnix, time.Now().Unix()+1) @@ -524,8 +524,8 @@ func TestFetchOperationsRejectsStateHistoryConflictBeforeWrites(t *testing.T) { _, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ ObservedOperations: []*codespacev1.ObservedOperation{ - {CodespaceUuid: renewedUUID, OperationRversion: 36}, - {CodespaceUuid: conflictUUID, OperationRversion: 38}, + {RuntimeUuid: renewedUUID, OperationRversion: 36}, + {RuntimeUuid: conflictUUID, OperationRversion: 38}, }, }) require.ErrorIs(t, err, ErrFetchStateHistoryConflict) @@ -578,14 +578,14 @@ func TestFetchOperationsReturnsCurrentPayloadForLowerObservedVersion(t *testing. result, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ ObservedOperations: []*codespacev1.ObservedOperation{{ - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, OperationRversion: 38, }}, }) require.NoError(t, err) require.Len(t, result.Operations, 1) assert.Empty(t, result.RenewedLeases) - assert.Equal(t, codespaceUUID, result.Operations[0].GetCodespaceUuid()) + assert.Equal(t, codespaceUUID, result.Operations[0].GetRuntimeUuid()) assert.EqualValues(t, 39, result.Operations[0].GetOperationRversion()) assert.NotNil(t, result.Operations[0].GetStop()) assert.Greater(t, loadServiceCodespace(t, codespaceUUID).OperationDeadlineUnix, time.Now().Unix()+1) @@ -613,7 +613,7 @@ func TestFetchOperationsClaimsCleanupStop(t *testing.T) { require.NoError(t, err) require.Len(t, result.Operations, 1) assert.NotNil(t, result.Operations[0].GetStop()) - assert.Equal(t, codespaceUUID, result.Operations[0].GetCodespaceUuid()) + assert.Equal(t, codespaceUUID, result.Operations[0].GetRuntimeUuid()) assert.Equal(t, codespace_model.OperationStatusRunning, loadServiceCodespace(t, codespaceUUID).OperationStatus) } @@ -637,7 +637,7 @@ func TestFetchOperationsRejectsStateHistoryConflict(t *testing.T) { _, err := FetchOperations(t.Context(), manager, FetchOperationsOptions{ ObservedOperations: []*codespacev1.ObservedOperation{{ - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, OperationRversion: 35, }}, }) diff --git a/services/codespace/governance.go b/services/codespace/governance.go index 1b0b97490a509..dc2f1deac9d40 100644 --- a/services/codespace/governance.go +++ b/services/codespace/governance.go @@ -56,6 +56,7 @@ type GovernanceList struct { // GovernanceView contains only the fields non-creator governance pages may show. type GovernanceView struct { + ID int64 UUID string ShortUUID string DisplayStatus string @@ -157,6 +158,7 @@ func governanceCodespaceView(ctx context.Context, codespace *codespace_model.Cod applyCreatorDisplayState(ctx, codespace, view, manager, false) result := &GovernanceView{ + ID: codespace.ID, UUID: codespace.UUID, ShortUUID: shortCodespaceUUID(codespace.UUID), DisplayStatus: view.DisplayStatus, diff --git a/services/codespace/idle_stop.go b/services/codespace/idle_stop.go index 806b319f44fd9..d5b6f1c7d9e61 100644 --- a/services/codespace/idle_stop.go +++ b/services/codespace/idle_stop.go @@ -6,6 +6,7 @@ package codespace import ( "context" "errors" + "fmt" "time" codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" @@ -176,3 +177,7 @@ func settingsChanged(settings RuntimeSettings, opts RequestIdleStopOptions) bool func codespaceStateLockKey(codespaceUUID string) string { return "codespace_interaction_" + codespaceUUID } + +func codespaceRowLockKey(codespaceID int64) string { + return fmt.Sprintf("codespace_interaction_id_%d", codespaceID) +} diff --git a/services/codespace/interaction.go b/services/codespace/interaction.go index dcb70f61f170e..b0249b776ae72 100644 --- a/services/codespace/interaction.go +++ b/services/codespace/interaction.go @@ -30,8 +30,8 @@ var ( // ContinueCodespaceOptions identifies one creator keep-alive action. type ContinueCodespaceOptions struct { - UserID int64 - CodespaceUUID string + UserID int64 + CodespaceID int64 } // ContinueCodespaceResult contains the new interaction generation. @@ -42,7 +42,7 @@ type ContinueCodespaceResult struct { // UpdateAutoStopOptions contains one creator auto-stop settings update. type UpdateAutoStopOptions struct { UserID int64 - CodespaceUUID string + CodespaceID int64 Mode string CustomTimeoutSeconds int64 } @@ -59,14 +59,14 @@ func ContinueCodespace(ctx context.Context, opts ContinueCodespaceOptions) (*Con if !setting.Codespace.Enabled { return nil, ErrInteractionStateUnavailable } - if err := validateCreatorInteractionOptions(opts.UserID, opts.CodespaceUUID); err != nil { + if err := validateCreatorInteractionOptions(opts.UserID, opts.CodespaceID); err != nil { return nil, err } var result *ContinueCodespaceResult - err := globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + err := globallock.LockAndDo(ctx, codespaceRowLockKey(opts.CodespaceID), func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error { - codespace, err := loadCreatorCodespace(ctx, opts.UserID, opts.CodespaceUUID) + codespace, err := loadCreatorCodespace(ctx, opts.UserID, opts.CodespaceID) if err != nil { return err } @@ -97,14 +97,14 @@ func UpdateAutoStop(ctx context.Context, opts UpdateAutoStopOptions) (*UpdateAut if err != nil { return nil, err } - if err := validateCreatorInteractionOptions(opts.UserID, opts.CodespaceUUID); err != nil { + if err := validateCreatorInteractionOptions(opts.UserID, opts.CodespaceID); err != nil { return nil, err } var result *UpdateAutoStopResult - err = globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + err = globallock.LockAndDo(ctx, codespaceRowLockKey(opts.CodespaceID), func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error { - codespace, err := loadCreatorCodespace(ctx, opts.UserID, opts.CodespaceUUID) + codespace, err := loadCreatorCodespace(ctx, opts.UserID, opts.CodespaceID) if err != nil { return err } @@ -150,16 +150,19 @@ func UpdateAutoStop(ctx context.Context, opts UpdateAutoStopOptions) (*UpdateAut return result, nil } -func validateCreatorInteractionOptions(userID int64, codespaceUUID string) error { +func validateCreatorInteractionOptions(userID, codespaceID int64) error { if userID <= 0 { return errors.New("user_id must be positive") } - return codespace_model.ValidateUUID(codespaceUUID) + if codespaceID <= 0 { + return errors.New("codespace_id must be positive") + } + return nil } -func loadCreatorCodespace(ctx context.Context, userID int64, codespaceUUID string) (*codespace_model.Codespace, error) { +func loadCreatorCodespace(ctx context.Context, userID, codespaceID int64) (*codespace_model.Codespace, error) { codespace := new(codespace_model.Codespace) - has, err := db.GetEngine(ctx).Where("uuid = ?", codespaceUUID).Get(codespace) + has, err := db.GetEngine(ctx).ID(codespaceID).Get(codespace) if err != nil { return nil, err } diff --git a/services/codespace/interaction_test.go b/services/codespace/interaction_test.go index 3eb9bde1eb6d3..97a71f5090d76 100644 --- a/services/codespace/interaction_test.go +++ b/services/codespace/interaction_test.go @@ -33,8 +33,8 @@ func TestContinueCodespaceCancelsQueuedIdleStop(t *testing.T) { }) result, err := ContinueCodespace(t.Context(), ContinueCodespaceOptions{ - UserID: 1, - CodespaceUUID: codespaceUUID, + UserID: 1, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), }) require.NoError(t, err) assert.EqualValues(t, 5, result.InteractionGeneration) @@ -59,8 +59,8 @@ func TestContinueCodespaceKeepsLifecycleUpdatedUnixWithoutIdleStop(t *testing.T) }) result, err := ContinueCodespace(t.Context(), ContinueCodespaceOptions{ - UserID: 1, - CodespaceUUID: codespaceUUID, + UserID: 1, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), }) require.NoError(t, err) assert.EqualValues(t, 5, result.InteractionGeneration) @@ -86,8 +86,8 @@ func TestContinueCodespaceRejectsRunningStopAndVersionExhausted(t *testing.T) { OperationDeadlineUnix: time.Now().Add(time.Minute).Unix(), }) _, err := ContinueCodespace(t.Context(), ContinueCodespaceOptions{ - UserID: 1, - CodespaceUUID: runningStopUUID, + UserID: 1, + CodespaceID: codespaceIDByUUID(t, runningStopUUID), }) require.ErrorIs(t, err, ErrInteractionStateUnavailable) @@ -98,8 +98,8 @@ func TestContinueCodespaceRejectsRunningStopAndVersionExhausted(t *testing.T) { InteractionGeneration: math.MaxInt64, }) _, err = ContinueCodespace(t.Context(), ContinueCodespaceOptions{ - UserID: 1, - CodespaceUUID: exhaustedUUID, + UserID: 1, + CodespaceID: codespaceIDByUUID(t, exhaustedUUID), }) require.ErrorIs(t, err, ErrInteractionVersionExhausted) } @@ -120,7 +120,7 @@ func TestUpdateAutoStopCancelsQueuedIdleOnlyWhenRuntimePolicyChanges(t *testing. }) result, err := UpdateAutoStop(t.Context(), UpdateAutoStopOptions{ UserID: 1, - CodespaceUUID: changedUUID, + CodespaceID: codespaceIDByUUID(t, changedUUID), Mode: codespace_model.AutoStopModeCustom, CustomTimeoutSeconds: int64((10 * time.Minute) / time.Second), }) @@ -145,7 +145,7 @@ func TestUpdateAutoStopCancelsQueuedIdleOnlyWhenRuntimePolicyChanges(t *testing. }) _, err = UpdateAutoStop(t.Context(), UpdateAutoStopOptions{ UserID: 1, - CodespaceUUID: samePolicyUUID, + CodespaceID: codespaceIDByUUID(t, samePolicyUUID), Mode: codespace_model.AutoStopModeCustom, CustomTimeoutSeconds: int64(setting.Codespace.AutoStopDefaultTimeout / time.Second), }) @@ -167,9 +167,9 @@ func TestUpdateAutoStopKeepsLifecycleUpdatedUnixWithoutIdleStopCancellation(t *t }) _, err := UpdateAutoStop(t.Context(), UpdateAutoStopOptions{ - UserID: 1, - CodespaceUUID: codespaceUUID, - Mode: codespace_model.AutoStopModeNever, + UserID: 1, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), + Mode: codespace_model.AutoStopModeNever, }) require.NoError(t, err) row := loadServiceCodespace(t, codespaceUUID) @@ -187,9 +187,9 @@ func TestUpdateAutoStopValidationAndState(t *testing.T) { Status: codespace_model.StatusStopped, }) _, err := UpdateAutoStop(t.Context(), UpdateAutoStopOptions{ - UserID: 1, - CodespaceUUID: stoppedUUID, - Mode: codespace_model.AutoStopModeNever, + UserID: 1, + CodespaceID: codespaceIDByUUID(t, stoppedUUID), + Mode: codespace_model.AutoStopModeNever, }) require.NoError(t, err) row := loadServiceCodespace(t, stoppedUUID) @@ -198,7 +198,7 @@ func TestUpdateAutoStopValidationAndState(t *testing.T) { _, err = UpdateAutoStop(t.Context(), UpdateAutoStopOptions{ UserID: 1, - CodespaceUUID: stoppedUUID, + CodespaceID: codespaceIDByUUID(t, stoppedUUID), Mode: codespace_model.AutoStopModeCustom, CustomTimeoutSeconds: int64((setting.Codespace.AutoStopMinTimeout / time.Second) - 1), }) @@ -210,9 +210,9 @@ func TestUpdateAutoStopValidationAndState(t *testing.T) { Status: codespace_model.StatusCreating, }) _, err = UpdateAutoStop(t.Context(), UpdateAutoStopOptions{ - UserID: 1, - CodespaceUUID: creatingUUID, - Mode: codespace_model.AutoStopModeNever, + UserID: 1, + CodespaceID: codespaceIDByUUID(t, creatingUUID), + Mode: codespace_model.AutoStopModeNever, }) require.ErrorIs(t, err, ErrInteractionStateUnavailable) } diff --git a/services/codespace/inventory.go b/services/codespace/inventory.go index f3a14ac079046..d73e5ef8e21a1 100644 --- a/services/codespace/inventory.go +++ b/services/codespace/inventory.go @@ -48,7 +48,7 @@ func ReportInstances(ctx context.Context, manager *codespace_model.Manager, opts reported := make(map[string]*codespacev1.RuntimeInstanceRef, len(opts.Instances)) response := &codespacev1.ReportInstancesResponse{Results: make([]*codespacev1.RuntimeInstanceResult, 0, len(opts.Instances))} for _, instance := range opts.Instances { - reported[instance.GetCodespaceUuid()] = instance + reported[instance.GetRuntimeUuid()] = instance if err := ensureInventoryGenerationCurrent(ctx, manager.ID, opts.InventoryGeneration); err != nil { return nil, err } @@ -79,13 +79,13 @@ func validateReportInstancesOptions(opts ReportInstancesOptions) error { if instance == nil { return errors.New("runtime instance is required") } - if err := codespace_model.ValidateUUID(instance.GetCodespaceUuid()); err != nil { + if err := codespace_model.ValidateUUID(instance.GetRuntimeUuid()); err != nil { return err } - if _, ok := seen[instance.GetCodespaceUuid()]; ok { - return fmt.Errorf("duplicate codespace_uuid %q", instance.GetCodespaceUuid()) + if _, ok := seen[instance.GetRuntimeUuid()]; ok { + return fmt.Errorf("duplicate codespace_uuid %q", instance.GetRuntimeUuid()) } - seen[instance.GetCodespaceUuid()] = struct{}{} + seen[instance.GetRuntimeUuid()] = struct{}{} if !validRuntimeInstanceState(instance.GetRuntimeState()) { return fmt.Errorf("invalid runtime_state %d", instance.GetRuntimeState()) } @@ -114,7 +114,7 @@ func precheckInventoryObservedVersions(ctx context.Context, managerID int64, ins continue } codespace := new(codespace_model.Codespace) - has, err := db.GetEngine(ctx).Where("uuid = ?", instance.GetCodespaceUuid()).Get(codespace) + has, err := db.GetEngine(ctx).Where("uuid = ?", instance.GetRuntimeUuid()).Get(codespace) if err != nil { return err } @@ -170,11 +170,11 @@ func ensureInventoryGenerationCurrent(ctx context.Context, managerID, inventoryG func processReportedRuntimeInstance(ctx context.Context, managerID int64, instance *codespacev1.RuntimeInstanceRef) (*codespacev1.RuntimeInstanceResult, error) { codespace := new(codespace_model.Codespace) - has, err := db.GetEngine(ctx).Where("uuid = ?", instance.GetCodespaceUuid()).Get(codespace) + has, err := db.GetEngine(ctx).Where("uuid = ?", instance.GetRuntimeUuid()).Get(codespace) if err != nil { return nil, err } - result := &codespacev1.RuntimeInstanceResult{CodespaceUuid: instance.GetCodespaceUuid()} + result := &codespacev1.RuntimeInstanceResult{RuntimeUuid: instance.GetRuntimeUuid()} if !has { result.Action = codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_CLEANUP_LOCAL_RUNTIME return result, nil diff --git a/services/codespace/inventory_test.go b/services/codespace/inventory_test.go index 6927a06fb19c8..06b52a22638a8 100644 --- a/services/codespace/inventory_test.go +++ b/services/codespace/inventory_test.go @@ -75,9 +75,9 @@ func TestReportInstancesReturnsSettingsAndActions(t *testing.T) { _, err := ReportInstances(t.Context(), manager, ReportInstancesOptions{ InventoryGeneration: 1, Instances: []*codespacev1.RuntimeInstanceRef{ - {CodespaceUuid: runningUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, - {CodespaceUuid: activeUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 81}, - {CodespaceUuid: runningUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED}, + {RuntimeUuid: runningUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, + {RuntimeUuid: activeUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 81}, + {RuntimeUuid: runningUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED}, }, }) require.Error(t, err) @@ -86,13 +86,13 @@ func TestReportInstancesReturnsSettingsAndActions(t *testing.T) { result, err := ReportInstances(t.Context(), manager, ReportInstancesOptions{ InventoryGeneration: 1, Instances: []*codespacev1.RuntimeInstanceRef{ - {CodespaceUuid: runningUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, - {CodespaceUuid: activeUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 81}, - {CodespaceUuid: stoppedUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, - {CodespaceUuid: failedUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, - {CodespaceUuid: otherUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, - {CodespaceUuid: unboundUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_CREATING}, - {CodespaceUuid: absentUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, + {RuntimeUuid: runningUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, + {RuntimeUuid: activeUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 81}, + {RuntimeUuid: stoppedUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, + {RuntimeUuid: failedUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, + {RuntimeUuid: otherUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, + {RuntimeUuid: unboundUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_CREATING}, + {RuntimeUuid: absentUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, }, }) require.NoError(t, err) @@ -129,8 +129,8 @@ func TestReportInstancesReturnsDisabledRuntimeSettings(t *testing.T) { result, err := ReportInstances(t.Context(), manager, ReportInstancesOptions{ InventoryGeneration: 11, Instances: []*codespacev1.RuntimeInstanceRef{{ - CodespaceUuid: codespaceUUID, - RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, + RuntimeUuid: codespaceUUID, + RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, }}, }) require.NoError(t, err) @@ -169,9 +169,9 @@ func TestReportInstancesTransitionAndClearActions(t *testing.T) { result, err := ReportInstances(t.Context(), manager, ReportInstancesOptions{ InventoryGeneration: 2, Instances: []*codespacev1.RuntimeInstanceRef{ - {CodespaceUuid: stoppedRuntimeUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED}, - {CodespaceUuid: failedRuntimeUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, - {CodespaceUuid: clearUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 93}, + {RuntimeUuid: stoppedRuntimeUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED}, + {RuntimeUuid: failedRuntimeUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, + {RuntimeUuid: clearUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 93}, }, }) require.NoError(t, err) @@ -294,7 +294,7 @@ func TestReportInstancesRejectsStateHistoryConflict(t *testing.T) { _, err := ReportInstances(t.Context(), manager, ReportInstancesOptions{ InventoryGeneration: 4, Instances: []*codespacev1.RuntimeInstanceRef{{ - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 112, }}, diff --git a/services/codespace/lifecycle_action.go b/services/codespace/lifecycle_action.go index 33e315e3793c4..2b1615f6e08fd 100644 --- a/services/codespace/lifecycle_action.go +++ b/services/codespace/lifecycle_action.go @@ -28,8 +28,8 @@ var ( // LifecycleActionOptions identifies one creator lifecycle request. type LifecycleActionOptions struct { - UserID int64 - CodespaceUUID string + UserID int64 + CodespaceID int64 } // LifecycleActionResult contains the accepted operation state. @@ -64,7 +64,7 @@ func applyCreatorLifecycleAction(ctx context.Context, opts LifecycleActionOption } var result *LifecycleActionResult - err := globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + err := globallock.LockAndDo(ctx, codespaceRowLockKey(opts.CodespaceID), func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error { codespace, err := loadLifecycleActionCodespace(ctx, opts) if err != nil { @@ -217,12 +217,15 @@ func validateLifecycleActionOptions(opts LifecycleActionOptions) error { if opts.UserID <= 0 { return errors.New("user_id must be positive") } - return codespace_model.ValidateUUID(opts.CodespaceUUID) + if opts.CodespaceID <= 0 { + return errors.New("codespace_id must be positive") + } + return nil } func loadLifecycleActionCodespace(ctx context.Context, opts LifecycleActionOptions) (*codespace_model.Codespace, error) { codespace := new(codespace_model.Codespace) - has, err := db.GetEngine(ctx).Where("uuid = ?", opts.CodespaceUUID).Get(codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceID).Get(codespace) if err != nil { return nil, err } diff --git a/services/codespace/lifecycle_action_test.go b/services/codespace/lifecycle_action_test.go index 464dbfecd14d6..2fac61d9949bb 100644 --- a/services/codespace/lifecycle_action_test.go +++ b/services/codespace/lifecycle_action_test.go @@ -25,7 +25,7 @@ func TestStopCodespaceQueuesUserStopAndTakesQueuedIdleStop(t *testing.T) { Status: codespace_model.StatusRunning, OperationRVersion: 15, }) - result, err := StopCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: codespaceUUID}) + result, err := StopCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, codespaceUUID)}) require.NoError(t, err) assert.Equal(t, codespace_model.StatusRunning, result.Status) assert.Equal(t, codespace_model.OperationStop, result.OperationType) @@ -45,7 +45,7 @@ func TestStopCodespaceQueuesUserStopAndTakesQueuedIdleStop(t *testing.T) { OperationCreatedUnix: time.Now().Unix(), }) updatedUnix := loadServiceCodespace(t, idleUUID).UpdatedUnix - result, err = StopCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: idleUUID}) + result, err = StopCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, idleUUID)}) require.NoError(t, err) assert.EqualValues(t, 17, result.OperationRVersion) row = loadServiceCodespace(t, idleUUID) @@ -67,7 +67,7 @@ func TestStopCodespaceRejectsActiveUserStop(t *testing.T) { OperationTrigger: codespace_model.OperationTriggerUser, OperationCreatedUnix: time.Now().Unix(), }) - _, err := StopCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: queuedUUID}) + _, err := StopCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, queuedUUID)}) require.ErrorIs(t, err, ErrLifecycleActionStateUnavailable) runningUUID := "68686868-6868-4686-8686-686868686870" @@ -82,7 +82,7 @@ func TestStopCodespaceRejectsActiveUserStop(t *testing.T) { OperationStartedUnix: time.Now().Unix(), OperationDeadlineUnix: time.Now().Add(time.Minute).Unix(), }) - _, err = StopCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: runningUUID}) + _, err = StopCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, runningUUID)}) require.ErrorIs(t, err, ErrLifecycleActionStateUnavailable) } @@ -97,7 +97,7 @@ func TestResumeCodespaceQueuesResume(t *testing.T) { OperationRVersion: 18, InteractionGeneration: 7, }) - result, err := ResumeCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: codespaceUUID}) + result, err := ResumeCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, codespaceUUID)}) require.NoError(t, err) assert.Equal(t, codespace_model.StatusStopped, result.Status) assert.Equal(t, codespace_model.OperationResume, result.OperationType) @@ -122,7 +122,7 @@ func TestDeleteCodespacePhysicalForUnboundCreatingAndFailed(t *testing.T) { OperationTrigger: codespace_model.OperationTriggerUser, OperationCreatedUnix: time.Now().Unix(), }) - result, err := DeleteCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: creatingUUID}) + result, err := DeleteCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, creatingUUID)}) require.NoError(t, err) assert.True(t, result.Deleted) assertServiceNotExists(t, new(codespace_model.Codespace), "uuid = ?", creatingUUID) @@ -133,7 +133,7 @@ func TestDeleteCodespacePhysicalForUnboundCreatingAndFailed(t *testing.T) { Status: codespace_model.StatusFailed, }) insertServiceCredentials(t, failedUUID) - result, err = DeleteCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: failedUUID}) + result, err = DeleteCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, failedUUID)}) require.NoError(t, err) assert.True(t, result.Deleted) assertServiceNotExists(t, new(codespace_model.Codespace), "uuid = ?", failedUUID) @@ -205,7 +205,7 @@ func TestDeleteCodespaceQueuesBoundDeleteAndReplacesOperation(t *testing.T) { }) insertServiceCredentials(t, tc.uuid) - result, err := DeleteCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: tc.uuid}) + result, err := DeleteCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, tc.uuid)}) require.NoError(t, err) assert.False(t, result.Deleted) assert.Equal(t, codespace_model.StatusDeleting, result.Status) @@ -235,9 +235,9 @@ func TestLifecycleActionValidation(t *testing.T) { OperationTrigger: codespace_model.OperationTriggerUser, OperationCreatedUnix: time.Now().Unix(), }) - _, err := ResumeCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: runningUUID}) + _, err := ResumeCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, runningUUID)}) require.ErrorIs(t, err, ErrLifecycleActionStateUnavailable) - _, err = DeleteCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceUUID: runningUUID}) + _, err = DeleteCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, runningUUID)}) require.ErrorIs(t, err, ErrLifecycleActionVersionExhausted) } @@ -252,7 +252,7 @@ func TestLifecycleActionsRequireCreator(t *testing.T) { OperationRVersion: 24, }) - _, err := StopCodespace(t.Context(), LifecycleActionOptions{UserID: 2, CodespaceUUID: codespaceUUID}) + _, err := StopCodespace(t.Context(), LifecycleActionOptions{UserID: 2, CodespaceID: codespaceIDByUUID(t, codespaceUUID)}) require.ErrorIs(t, err, ErrLifecycleActionPermissionDenied) row := loadServiceCodespace(t, codespaceUUID) assert.Equal(t, codespace_model.StatusRunning, row.Status) diff --git a/services/codespace/lifecycle_test.go b/services/codespace/lifecycle_test.go index 704eb3de5448b..af380c0fd0f00 100644 --- a/services/codespace/lifecycle_test.go +++ b/services/codespace/lifecycle_test.go @@ -53,6 +53,11 @@ func TestFinalizeOperationResumeFailedTransaction(t *testing.T) { assertServiceExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) } +func codespaceIDByUUID(t *testing.T, codespaceUUID string) int64 { + t.Helper() + return loadServiceCodespace(t, codespaceUUID).ID +} + func TestFinalizeOperationRejectsWrongManagerAsStale(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) @@ -370,6 +375,15 @@ func loadServiceCodespace(t *testing.T, codespaceUUID string) *codespace_model.C return codespace } +func loadServiceCodespaceByID(t *testing.T, codespaceID int64) *codespace_model.Codespace { + t.Helper() + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(t.Context()).ID(codespaceID).Get(codespace) + require.NoError(t, err) + require.True(t, has) + return codespace +} + func assertServiceExists(t *testing.T, bean any, query string, args ...any) { t.Helper() has, err := db.GetEngine(t.Context()).Where(query, args...).Exist(bean) diff --git a/services/codespace/log.go b/services/codespace/log.go index bc2948d961453..08670c39a0697 100644 --- a/services/codespace/log.go +++ b/services/codespace/log.go @@ -73,10 +73,10 @@ type UpdateLogOptions struct { // ReadLogOptions identifies one user-facing log page request. type ReadLogOptions struct { - UserID int64 - CodespaceUUID string - Offset int64 - Limit int64 + UserID int64 + CodespaceID int64 + Offset int64 + Limit int64 } // ReadLogLine contains one parsed user-facing log line. @@ -221,8 +221,8 @@ func ReadLog(ctx context.Context, opts ReadLogOptions) (*ReadLogResult, error) { if opts.UserID <= 0 { return nil, fmt.Errorf("%w: user_id must be positive", ErrReadLogInvalidArgument) } - if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { - return nil, fmt.Errorf("%w: %w", ErrReadLogInvalidArgument, err) + if opts.CodespaceID <= 0 { + return nil, fmt.Errorf("%w: codespace_id must be positive", ErrReadLogInvalidArgument) } if opts.Offset < 0 { return nil, &LogOffsetError{Err: ErrReadLogInvalidArgument, CurrentOffset: 0} @@ -232,7 +232,7 @@ func ReadLog(ctx context.Context, opts ReadLogOptions) (*ReadLogResult, error) { } codespace := new(codespace_model.Codespace) - has, err := db.GetEngine(ctx).Where("uuid = ?", opts.CodespaceUUID).Get(codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceID).Get(codespace) if err != nil { return nil, err } @@ -248,6 +248,9 @@ func ReadLog(ctx context.Context, opts ReadLogOptions) (*ReadLogResult, error) { if opts.Offset == codespace.LogSize { return &ReadLogResult{Offset: opts.Offset, NextOffset: opts.Offset, EOF: true, OperationActive: hasActiveOperation(codespace), Lines: []ReadLogLine{}}, nil } + if codespace.UUID == "" { + return &ReadLogResult{Offset: opts.Offset, NextOffset: opts.Offset, EOF: true, OperationActive: hasActiveOperation(codespace), Lines: []ReadLogLine{}}, nil + } lines, nextOffset, eof, truncated, err := readLogLines(ctx, codespaceLogFilename(codespace.UUID), opts.Offset, codespace.LogSize, opts.Limit) if err != nil { return nil, err diff --git a/services/codespace/log_test.go b/services/codespace/log_test.go index 657d0a707db0f..3f8bd23fccb7f 100644 --- a/services/codespace/log_test.go +++ b/services/codespace/log_test.go @@ -331,10 +331,10 @@ func TestReadLogPagesByReturnedOffset(t *testing.T) { require.NoError(t, err) firstPage, err := ReadLog(t.Context(), ReadLogOptions{ - UserID: 1, - CodespaceUUID: codespaceUUID, - Offset: 0, - Limit: 1, + UserID: 1, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), + Offset: 0, + Limit: 1, }) require.NoError(t, err) assert.EqualValues(t, 0, firstPage.Offset) @@ -345,10 +345,10 @@ func TestReadLogPagesByReturnedOffset(t *testing.T) { assert.Equal(t, "first", firstPage.Lines[0].Message) secondPage, err := ReadLog(t.Context(), ReadLogOptions{ - UserID: 1, - CodespaceUUID: codespaceUUID, - Offset: firstPage.NextOffset, - Limit: 1, + UserID: 1, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), + Offset: firstPage.NextOffset, + Limit: 1, }) require.NoError(t, err) assert.False(t, secondPage.EOF) @@ -357,10 +357,10 @@ func TestReadLogPagesByReturnedOffset(t *testing.T) { assert.Equal(t, "second", secondPage.Lines[0].Message) eofPage, err := ReadLog(t.Context(), ReadLogOptions{ - UserID: 1, - CodespaceUUID: codespaceUUID, - Offset: result.NextOffset, - Limit: LogReadMaxBytes, + UserID: 1, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), + Offset: result.NextOffset, + Limit: LogReadMaxBytes, }) require.NoError(t, err) assert.True(t, eofPage.EOF) @@ -379,9 +379,9 @@ func TestReadLogReportsOperationActivity(t *testing.T) { }) result, err := ReadLog(t.Context(), ReadLogOptions{ - UserID: 1, - CodespaceUUID: codespaceUUID, - Limit: LogReadMaxBytes, + UserID: 1, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), + Limit: LogReadMaxBytes, }) require.NoError(t, err) assert.True(t, result.EOF) @@ -399,9 +399,9 @@ func TestReadLogRequiresCreator(t *testing.T) { }) _, err := ReadLog(t.Context(), ReadLogOptions{ - UserID: 2, - CodespaceUUID: codespaceUUID, - Limit: LogReadMaxBytes, + UserID: 2, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), + Limit: LogReadMaxBytes, }) require.ErrorIs(t, err, ErrReadLogPermissionDenied) } @@ -434,10 +434,10 @@ func TestReadLogRejectsOffsetPastEOF(t *testing.T) { require.NoError(t, err) _, err = ReadLog(t.Context(), ReadLogOptions{ - UserID: 1, - CodespaceUUID: codespaceUUID, - Offset: result.NextOffset + 1, - Limit: LogReadMaxBytes, + UserID: 1, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), + Offset: result.NextOffset + 1, + Limit: LogReadMaxBytes, }) var offsetErr *LogOffsetError require.ErrorAs(t, err, &offsetErr) diff --git a/services/codespace/manager.go b/services/codespace/manager.go index c0397e8090923..260de278fbbe6 100644 --- a/services/codespace/manager.go +++ b/services/codespace/manager.go @@ -14,10 +14,8 @@ import ( codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" codespace_model "gitea.dev/models/codespace" "gitea.dev/models/db" - user_model "gitea.dev/models/user" "gitea.dev/modules/globallock" "gitea.dev/modules/json" - "gitea.dev/modules/setting" ) var ( @@ -28,18 +26,16 @@ var ( const managerMaxEnvironments = 64 var ( - // ErrRegistrationUnauthenticated is returned when a registration token is not current. - ErrRegistrationUnauthenticated = errors.New("manager registration unauthenticated") - // ErrRegistrationStateUnavailable is returned when Codespace is not accepting new Manager registrations. - ErrRegistrationStateUnavailable = errors.New("manager registration state unavailable") // ErrManagerUnregistered is returned when the Manager ID has no current row. ErrManagerUnregistered = errors.New("manager unregistered") // ErrManagerUnauthenticated is returned when the Manager credential is not valid. ErrManagerUnauthenticated = errors.New("manager unauthenticated") - // ErrDeclareGatewayURLConflict is returned when another Manager already uses the Gateway URL. - ErrDeclareGatewayURLConflict = errors.New("manager gateway url conflict") - // ErrDeclareGatewaySSHAddrConflict is returned when another Manager already uses the Gateway SSH address. - ErrDeclareGatewaySSHAddrConflict = errors.New("manager gateway ssh address conflict") + // ErrBindRuntimeIdentityNotFound is returned when the create operation cannot be found. + ErrBindRuntimeIdentityNotFound = errors.New("runtime identity target not found") + // ErrBindRuntimeIdentityStateConflict is returned when the create operation cannot accept a runtime UUID. + ErrBindRuntimeIdentityStateConflict = errors.New("runtime identity state conflict") + // ErrBindRuntimeIdentityConflict is returned when the runtime UUID is already bound elsewhere. + ErrBindRuntimeIdentityConflict = errors.New("runtime identity conflict") ) // DeclareManagerOptions contains the full Manager declaration accepted by Gitea. @@ -55,70 +51,61 @@ type DeclareManagerOptions struct { GatewaySSHHostKeyUpdatedUnix int64 } +// BindRuntimeIdentityOptions contains the manager-created runtime identity for one create operation. +type BindRuntimeIdentityOptions struct { + CodespaceID int64 + OperationRVersion int64 + RuntimeUUID string +} + // ManagerEnvironmentDeclaration is one environment in a Manager declaration snapshot. type ManagerEnvironmentDeclaration struct { Tag string `json:"tag"` Description string `json:"description,omitempty"` } -// RegisterManager exchanges the current site or personal registration token for a Manager identity. -func RegisterManager(ctx context.Context, registrationToken string) (*codespace_model.Manager, string, error) { - if !setting.Codespace.Enabled { - return nil, "", ErrRegistrationStateUnavailable +// BindRuntimeIdentity stores the runtime UUID allocated by the Manager for an active create. +func BindRuntimeIdentity(ctx context.Context, manager *codespace_model.Manager, opts BindRuntimeIdentityOptions) (string, error) { + if manager == nil || manager.ID <= 0 || opts.CodespaceID <= 0 || opts.OperationRVersion <= 0 { + return "", ErrBindRuntimeIdentityNotFound } - registrationToken = strings.TrimSpace(registrationToken) - if registrationToken == "" { - return nil, "", ErrRegistrationUnauthenticated + if err := codespace_model.ValidateUUID(opts.RuntimeUUID); err != nil { + return "", err } - - token, err := loadRegistrationTokenByValue(ctx, registrationToken) - if err != nil { - return nil, "", err - } - if token == nil { - return nil, "", ErrRegistrationUnauthenticated - } - - var manager *codespace_model.Manager - var secret string - err = globallock.LockAndDo(ctx, codespaceUserRelationLockKey(token.UserID), func(ctx context.Context) error { + return opts.RuntimeUUID, globallock.LockAndDo(ctx, codespaceStateLockKey(opts.RuntimeUUID), func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error { - currentToken, err := loadRegistrationTokenByValue(ctx, registrationToken) + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceID).Get(codespace) if err != nil { return err } - if currentToken == nil || currentToken.UserID != token.UserID { - return ErrRegistrationUnauthenticated + if !has || codespace.ManagerID != manager.ID || codespace.OperationRVersion != opts.OperationRVersion || + codespace.OperationType != codespace_model.OperationCreate || codespace.OperationStatus != codespace_model.OperationStatusRunning { + return ErrBindRuntimeIdentityNotFound } - if currentToken.UserID > 0 { - user, err := user_model.GetUserByID(ctx, currentToken.UserID) - if err != nil { - if user_model.IsErrUserNotExist(err) { - return ErrRegistrationUnauthenticated - } - return err - } - if user.Type != user_model.UserTypeIndividual { - return ErrRegistrationUnauthenticated - } + if codespace.UUID == opts.RuntimeUUID { + return nil } - manager = &codespace_model.Manager{ - UserID: currentToken.UserID, - RuntimeState: codespace_model.ManagerRuntimeStateRecovering, - TagsJSON: "[]", - CreatedUnix: time.Now().Unix(), + if codespace.UUID != "" { + return ErrBindRuntimeIdentityStateConflict } - secret = manager.GenerateManagerSecret() - if _, err := db.GetEngine(ctx).Insert(manager); err != nil { + used, err := db.GetEngine(ctx).Where("uuid = ? AND id <> ?", opts.RuntimeUUID, codespace.ID).Exist(new(codespace_model.Codespace)) + if err != nil { return err } - return nil + if used { + return ErrBindRuntimeIdentityConflict + } + affected, err := db.GetEngine(ctx).Where("id = ? AND uuid = ?", codespace.ID, "").Cols("uuid", "updated_unix").Update(&codespace_model.Codespace{ + UUID: opts.RuntimeUUID, + UpdatedUnix: time.Now().Unix(), + }) + if err == nil && affected == 0 { + return ErrBindRuntimeIdentityStateConflict + } + return err }) }) - if err != nil { - return nil, "", err - } - return manager, secret, nil } // AuthenticateManager verifies a Manager id and plaintext secret. @@ -171,13 +158,6 @@ func DeclareManager(ctx context.Context, manager *codespace_model.Manager, opts if !has { return ErrManagerUnregistered } - if err := checkManagerAddressConflict(ctx, currentManager.ID, codespace_model.ManagerAddressGateway, opts.GatewayURL); err != nil { - return err - } - if err := checkManagerAddressConflict(ctx, currentManager.ID, codespace_model.ManagerAddressSSH, opts.GatewaySSHAddr); err != nil { - return err - } - now := time.Now().Unix() updates := &codespace_model.Manager{ Name: opts.Name, @@ -214,24 +194,6 @@ func DeclareManager(ctx context.Context, manager *codespace_model.Manager, opts }) } -func checkManagerAddressConflict(ctx context.Context, managerID int64, kind, address string) error { - existing := new(codespace_model.ManagerAddress) - has, err := db.GetEngine(ctx). - Where("kind = ? AND address = ? AND manager_id <> ?", kind, address, managerID). - Get(existing) - if err != nil || !has { - return err - } - switch kind { - case codespace_model.ManagerAddressGateway: - return ErrDeclareGatewayURLConflict - case codespace_model.ManagerAddressSSH: - return ErrDeclareGatewaySSHAddrConflict - default: - return errors.New("manager address conflict") - } -} - func normalizeDeclareManagerOptions(opts DeclareManagerOptions) (DeclareManagerOptions, error) { opts.Name = strings.TrimSpace(opts.Name) if opts.Name == "" { @@ -317,12 +279,3 @@ func normalizeManagerEnvironments(environments []*codespacev1.EnvironmentTag) ([ } return normalized, nil } - -func loadRegistrationTokenByValue(ctx context.Context, tokenValue string) (*codespace_model.ManagerToken, error) { - token := new(codespace_model.ManagerToken) - has, err := db.GetEngine(ctx).Where("token = ?", tokenValue).Get(token) - if err != nil || !has { - return nil, err - } - return token, nil -} diff --git a/services/codespace/manager_test.go b/services/codespace/manager_test.go index ff6dda8c67767..ad6ea265c9034 100644 --- a/services/codespace/manager_test.go +++ b/services/codespace/manager_test.go @@ -188,6 +188,142 @@ func TestWarnManagerGatewayAddressConflicts(t *testing.T) { require.NoError(t, WarnManagerGatewayAddressConflicts(t.Context())) } +func TestBindRuntimeIdentityAssignsManagerRuntimeUUID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + Status: codespace_model.StatusCreating, + OperationRVersion: 3, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + }) + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(t.Context()).Where("manager_id = ? AND uuid = ?", manager.ID, "").Get(codespace) + require.NoError(t, err) + require.True(t, has) + + runtimeUUID := "34343434-3434-4434-8434-343434343434" + boundUUID, err := BindRuntimeIdentity(t.Context(), manager, BindRuntimeIdentityOptions{ + CodespaceID: codespace.ID, + OperationRVersion: 3, + RuntimeUUID: runtimeUUID, + }) + require.NoError(t, err) + assert.Equal(t, runtimeUUID, boundUUID) + assert.Equal(t, runtimeUUID, loadServiceCodespace(t, runtimeUUID).UUID) + + boundUUID, err = BindRuntimeIdentity(t.Context(), manager, BindRuntimeIdentityOptions{ + CodespaceID: codespace.ID, + OperationRVersion: 3, + RuntimeUUID: runtimeUUID, + }) + require.NoError(t, err) + assert.Equal(t, runtimeUUID, boundUUID) + + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + Status: codespace_model.StatusCreating, + OperationRVersion: 4, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + }) + otherCodespace := new(codespace_model.Codespace) + has, err = db.GetEngine(t.Context()).Where("manager_id = ? AND uuid = ? AND id <> ?", manager.ID, "", codespace.ID).Get(otherCodespace) + require.NoError(t, err) + require.True(t, has) + _, err = BindRuntimeIdentity(t.Context(), manager, BindRuntimeIdentityOptions{ + CodespaceID: otherCodespace.ID, + OperationRVersion: 4, + RuntimeUUID: runtimeUUID, + }) + require.ErrorIs(t, err, ErrBindRuntimeIdentityConflict) +} + +func TestBindRuntimeIdentityRejectsInvalidOperationState(t *testing.T) { + runtimeUUID := "45454545-4545-4454-8454-454545454545" + + for _, tc := range []struct { + name string + setup func(*testing.T, *codespace_model.Manager) (*codespace_model.Codespace, *codespace_model.Manager, int64) + expected error + }{ + { + name: "wrong manager", + setup: func(t *testing.T, manager *codespace_model.Manager) (*codespace_model.Codespace, *codespace_model.Manager, int64) { + otherManager := insertServiceManager(t) + codespace := insertRuntimeIdentityTarget(t, manager, "") + return codespace, otherManager, codespace.OperationRVersion + }, + expected: ErrBindRuntimeIdentityNotFound, + }, + { + name: "wrong operation version", + setup: func(t *testing.T, manager *codespace_model.Manager) (*codespace_model.Codespace, *codespace_model.Manager, int64) { + codespace := insertRuntimeIdentityTarget(t, manager, "") + return codespace, manager, codespace.OperationRVersion + 1 + }, + expected: ErrBindRuntimeIdentityNotFound, + }, + { + name: "queued operation", + setup: func(t *testing.T, manager *codespace_model.Manager) (*codespace_model.Codespace, *codespace_model.Manager, int64) { + codespace := insertRuntimeIdentityTarget(t, manager, "") + codespace.OperationStatus = codespace_model.OperationStatusQueued + _, err := db.GetEngine(t.Context()).ID(codespace.ID).Cols("operation_status").Update(codespace) + require.NoError(t, err) + return codespace, manager, codespace.OperationRVersion + }, + expected: ErrBindRuntimeIdentityNotFound, + }, + { + name: "stop operation", + setup: func(t *testing.T, manager *codespace_model.Manager) (*codespace_model.Codespace, *codespace_model.Manager, int64) { + codespace := insertRuntimeIdentityTarget(t, manager, "") + codespace.OperationType = codespace_model.OperationStop + _, err := db.GetEngine(t.Context()).ID(codespace.ID).Cols("operation_type").Update(codespace) + require.NoError(t, err) + return codespace, manager, codespace.OperationRVersion + }, + expected: ErrBindRuntimeIdentityNotFound, + }, + { + name: "already bound", + setup: func(t *testing.T, manager *codespace_model.Manager) (*codespace_model.Codespace, *codespace_model.Manager, int64) { + codespace := insertRuntimeIdentityTarget(t, manager, "56565656-5656-4656-8656-565656565656") + return codespace, manager, codespace.OperationRVersion + }, + expected: ErrBindRuntimeIdentityStateConflict, + }, + } { + t.Run(tc.name, func(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + manager := insertServiceManager(t) + codespace, bindManager, operationRVersion := tc.setup(t, manager) + + _, err := BindRuntimeIdentity(t.Context(), bindManager, BindRuntimeIdentityOptions{ + CodespaceID: codespace.ID, + OperationRVersion: operationRVersion, + RuntimeUUID: runtimeUUID, + }) + require.ErrorIs(t, err, tc.expected) + }) + } +} + +func insertRuntimeIdentityTarget(t *testing.T, manager *codespace_model.Manager, runtimeUUID string) *codespace_model.Codespace { + t.Helper() + + codespace := &codespace_model.Codespace{ + UUID: runtimeUUID, + Status: codespace_model.StatusCreating, + OperationRVersion: 3, + OperationType: codespace_model.OperationCreate, + OperationStatus: codespace_model.OperationStatusRunning, + } + insertServiceCodespace(t, manager.ID, codespace) + return codespace +} + func TestCodespaceInitSkipsGatewayAddressValidationWhenDisabled(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) mockGatewayScopeSettings(t, "https://gitea.example.com/", "", false) diff --git a/services/codespace/open_token.go b/services/codespace/open_token.go index aaf526883128d..50a0d9ba68757 100644 --- a/services/codespace/open_token.go +++ b/services/codespace/open_token.go @@ -57,9 +57,9 @@ const ( // OpenEndpointOptions identifies one authenticated Gitea Web open request. type OpenEndpointOptions struct { - UserID int64 - CodespaceUUID string - EndpointID string + UserID int64 + CodespaceID int64 + EndpointID string } // OpenEndpointResult contains the redirect target produced for one Web open request. @@ -104,8 +104,8 @@ func openEndpoint(ctx context.Context, opts OpenEndpointOptions) (*openEndpointR if err := validateOpenEndpointID(opts.EndpointID); err != nil { return nil, err } - if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { - return nil, err + if opts.CodespaceID <= 0 { + return nil, errors.New("codespace_id must be positive") } if !setting.Codespace.Enabled { return nil, fmt.Errorf("%w: %s", ErrOpenEndpointUnavailable, OpenTokenDeniedStateUnavailable) @@ -114,10 +114,10 @@ func openEndpoint(ctx context.Context, opts OpenEndpointOptions) (*openEndpointR var result *openEndpointResult var unavailableCategory string var tokenCacheKey string - err := globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + err := globallock.LockAndDo(ctx, codespaceRowLockKey(opts.CodespaceID), func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error { codespace := new(codespace_model.Codespace) - has, err := db.GetEngine(ctx).Where("uuid = ?", opts.CodespaceUUID).Get(codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceID).Get(codespace) if err != nil { return err } @@ -127,6 +127,10 @@ func openEndpoint(ctx context.Context, opts OpenEndpointOptions) (*openEndpointR if codespace.UserID != opts.UserID { return ErrOpenEndpointNotFound } + if codespace.UUID == "" { + unavailableCategory = OpenTokenDeniedMetadataRebuilding + return nil + } if codespace.Status != codespace_model.StatusRunning { unavailableCategory = OpenTokenDeniedCodespaceNotRunning return nil @@ -146,7 +150,7 @@ func openEndpoint(ctx context.Context, opts OpenEndpointOptions) (*openEndpointR if err := checkCodespaceCreatorForOpen(ctx, codespace, opts.UserID); err != nil { return err } - entry, hasEntry, err := getRuntimeMetadataEntry(opts.CodespaceUUID) + entry, hasEntry, err := getRuntimeMetadataEntry(codespace.UUID) if err != nil { return err } @@ -172,7 +176,7 @@ func openEndpoint(ctx context.Context, opts OpenEndpointOptions) (*openEndpointR now := time.Now().Unix() if err := putOpenTokenCacheEntry(tokenCacheKey, openTokenCacheEntry{ UserID: opts.UserID, - CodespaceUUID: opts.CodespaceUUID, + CodespaceUUID: codespace.UUID, EndpointID: opts.EndpointID, ManagerID: codespace.ManagerID, IssuedUnix: now, @@ -180,7 +184,7 @@ func openEndpoint(ctx context.Context, opts OpenEndpointOptions) (*openEndpointR }); err != nil { return err } - redirectURL, err := gatewayOpenURL(gatewayURL, opts.CodespaceUUID, opts.EndpointID, code) + redirectURL, err := gatewayOpenURL(gatewayURL, codespace.UUID, opts.EndpointID, code) if err != nil { return err } @@ -343,7 +347,7 @@ func ValidateOpenToken(ctx context.Context, manager *codespace_model.Manager, op Outcome: &codespacev1.ValidateOpenTokenResponse_Allowed{ Allowed: &codespacev1.OpenTokenBinding{ UserId: currentEntry.UserID, - CodespaceUuid: currentEntry.CodespaceUUID, + RuntimeUuid: currentEntry.CodespaceUUID, EndpointId: currentEntry.EndpointID, InteractionGeneration: nextGeneration, }, @@ -411,7 +415,7 @@ func openEndpointInfo(codespace *codespace_model.Codespace, metadata runtimeMeta if !found { return unavailableOpenEndpoint(OpenTokenDeniedEndpointNotFound), nil } - targetURL, err := gatewayEndpointURL(gatewayURL, opts.CodespaceUUID, opts.EndpointID) + targetURL, err := gatewayEndpointURL(gatewayURL, codespace.UUID, opts.EndpointID) if err != nil { return nil, err } diff --git a/services/codespace/open_token_test.go b/services/codespace/open_token_test.go index 7ee337ee72493..2bd889ef448d4 100644 --- a/services/codespace/open_token_test.go +++ b/services/codespace/open_token_test.go @@ -39,9 +39,9 @@ func TestOpenEndpointTokenAllowsAndConsumes(t *testing.T) { }))) issued, err := openEndpoint(t.Context(), OpenEndpointOptions{ - UserID: 1, - CodespaceUUID: codespaceUUID, - EndpointID: "app-3000", + UserID: 1, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), + EndpointID: "app-3000", }) require.NoError(t, err) require.Len(t, issued.code, 64) @@ -59,7 +59,7 @@ func TestOpenEndpointTokenAllowsAndConsumes(t *testing.T) { require.NoError(t, err) require.NotNil(t, validated.GetAllowed()) assert.EqualValues(t, 1, validated.GetAllowed().GetUserId()) - assert.Equal(t, codespaceUUID, validated.GetAllowed().GetCodespaceUuid()) + assert.Equal(t, codespaceUUID, validated.GetAllowed().GetRuntimeUuid()) assert.Equal(t, "app-3000", validated.GetAllowed().GetEndpointId()) assert.EqualValues(t, 7, validated.GetAllowed().GetInteractionGeneration()) assert.False(t, cache.GetCache().IsExist(openTokenCacheKey(issued.code))) @@ -83,9 +83,9 @@ func TestOpenEndpointHidesOtherCreatorCodespace(t *testing.T) { }) _, err := OpenEndpoint(t.Context(), OpenEndpointOptions{ - UserID: 2, - CodespaceUUID: codespaceUUID, - EndpointID: "workspace", + UserID: 2, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), + EndpointID: "workspace", }) require.ErrorIs(t, err, ErrOpenEndpointNotFound) } @@ -104,9 +104,9 @@ func TestValidateOpenTokenDeniesAndPreservesTemporarilyInvalidCode(t *testing.T) }) require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 82, []map[string]any{}))) issued, err := openEndpoint(t.Context(), OpenEndpointOptions{ - UserID: 1, - CodespaceUUID: codespaceUUID, - EndpointID: "workspace", + UserID: 1, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), + EndpointID: "workspace", }) require.NoError(t, err) @@ -174,9 +174,9 @@ func TestValidateOpenTokenEndpointMustRemainPrivate(t *testing.T) { {"endpoint_id": "app-3000", "label": "App", "public": false}, }))) issued, err := openEndpoint(t.Context(), OpenEndpointOptions{ - UserID: 1, - CodespaceUUID: codespaceUUID, - EndpointID: "app-3000", + UserID: 1, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), + EndpointID: "app-3000", }) require.NoError(t, err) require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, serviceRuntimeMetadataEntry(t, 84, []map[string]any{ @@ -239,9 +239,9 @@ func TestOpenEndpointPublicRedirectDoesNotIssueCodeOrAdvance(t *testing.T) { }))) result, err := OpenEndpoint(t.Context(), OpenEndpointOptions{ - UserID: 1, - CodespaceUUID: codespaceUUID, - EndpointID: "app-3000", + UserID: 1, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), + EndpointID: "app-3000", }) require.NoError(t, err) require.True(t, result.Public) diff --git a/services/codespace/session_revalidate.go b/services/codespace/session_revalidate.go index d491b210f575d..d21b20a863aaf 100644 --- a/services/codespace/session_revalidate.go +++ b/services/codespace/session_revalidate.go @@ -92,14 +92,14 @@ func RevalidateGatewaySession(ctx context.Context, manager *codespace_model.Mana func validateGatewaySessionBinding(request *codespacev1.RevalidateGatewaySessionRequest) (userID int64, codespaceUUID, endpointID string, sshSession bool, err error) { if endpoint := request.GetEndpoint(); endpoint != nil { userID = endpoint.GetUserId() - codespaceUUID = endpoint.GetCodespaceUuid() + codespaceUUID = endpoint.GetRuntimeUuid() endpointID = endpoint.GetEndpointId() if endpointID != workspaceEndpointID && !endpointIDPattern.MatchString(endpointID) { return 0, "", "", false, errors.New("invalid endpoint_id") } } else if ssh := request.GetSsh(); ssh != nil { userID = ssh.GetUserId() - codespaceUUID = ssh.GetCodespaceUuid() + codespaceUUID = ssh.GetRuntimeUuid() sshSession = true } else { return 0, "", "", false, errors.New("session is required") diff --git a/services/codespace/session_revalidate_test.go b/services/codespace/session_revalidate_test.go index 9629394e1d36e..fa5b05d2def2b 100644 --- a/services/codespace/session_revalidate_test.go +++ b/services/codespace/session_revalidate_test.go @@ -35,7 +35,7 @@ func TestRevalidateGatewaySessionAllowsPrivateEndpointAndSSH(t *testing.T) { endpoint, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ Session: &codespacev1.RevalidateGatewaySessionRequest_Endpoint{Endpoint: &codespacev1.EndpointSessionBinding{ - UserId: 1, CodespaceUuid: codespaceUUID, EndpointId: "app-3000", + UserId: 1, RuntimeUuid: codespaceUUID, EndpointId: "app-3000", }}, }) require.NoError(t, err) @@ -43,7 +43,7 @@ func TestRevalidateGatewaySessionAllowsPrivateEndpointAndSSH(t *testing.T) { workspace, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ Session: &codespacev1.RevalidateGatewaySessionRequest_Endpoint{Endpoint: &codespacev1.EndpointSessionBinding{ - UserId: 1, CodespaceUuid: codespaceUUID, EndpointId: "workspace", + UserId: 1, RuntimeUuid: codespaceUUID, EndpointId: "workspace", }}, }) require.NoError(t, err) @@ -51,7 +51,7 @@ func TestRevalidateGatewaySessionAllowsPrivateEndpointAndSSH(t *testing.T) { sshSession, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ Session: &codespacev1.RevalidateGatewaySessionRequest_Ssh{Ssh: &codespacev1.SSHSessionBinding{ - UserId: 1, CodespaceUuid: codespaceUUID, + UserId: 1, RuntimeUuid: codespaceUUID, }}, }) require.NoError(t, err) @@ -79,7 +79,7 @@ func TestRevalidateGatewaySessionDeniesChangedEndpointAndState(t *testing.T) { publicEndpoint, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ Session: &codespacev1.RevalidateGatewaySessionRequest_Endpoint{Endpoint: &codespacev1.EndpointSessionBinding{ - UserId: 1, CodespaceUuid: codespaceUUID, EndpointId: "public-api", + UserId: 1, RuntimeUuid: codespaceUUID, EndpointId: "public-api", }}, }) require.NoError(t, err) @@ -87,7 +87,7 @@ func TestRevalidateGatewaySessionDeniesChangedEndpointAndState(t *testing.T) { wrongUser, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ Session: &codespacev1.RevalidateGatewaySessionRequest_Ssh{Ssh: &codespacev1.SSHSessionBinding{ - UserId: 2, CodespaceUuid: codespaceUUID, + UserId: 2, RuntimeUuid: codespaceUUID, }}, }) require.NoError(t, err) @@ -106,7 +106,7 @@ func TestRevalidateGatewaySessionDeniesChangedEndpointAndState(t *testing.T) { require.NoError(t, putRuntimeMetadataEntry(activeUUID, serviceRuntimeMetadataEntry(t, 63, []map[string]any{}))) active, err := RevalidateGatewaySession(t.Context(), manager, &codespacev1.RevalidateGatewaySessionRequest{ Session: &codespacev1.RevalidateGatewaySessionRequest_Ssh{Ssh: &codespacev1.SSHSessionBinding{ - UserId: 1, CodespaceUuid: activeUUID, + UserId: 1, RuntimeUuid: activeUUID, }}, }) require.NoError(t, err) diff --git a/services/codespace/settings.go b/services/codespace/settings.go index 5bd2d87c1b09f..4937eb7c346e8 100644 --- a/services/codespace/settings.go +++ b/services/codespace/settings.go @@ -5,16 +5,14 @@ package codespace import ( "context" - "encoding/hex" "errors" "fmt" - "strings" + "time" codespace_model "gitea.dev/models/codespace" "gitea.dev/models/db" user_model "gitea.dev/models/user" "gitea.dev/modules/globallock" - "gitea.dev/modules/util" ) const ( @@ -47,6 +45,12 @@ type DeleteManagerOptions struct { Confirm bool } +// CreateManagerResult returns the Manager identity and one-time plaintext secret. +type CreateManagerResult struct { + ManagerID int64 + Secret string +} + // ManagerDetailOptions selects one Manager management page and its Codespace page. type ManagerDetailOptions struct { ManagerSettingsOptions @@ -62,10 +66,9 @@ type ManagerDetail struct { Total int64 } -// ManagerSettings contains registration token and Manager rows for settings pages. +// ManagerSettings contains Manager rows for settings pages. type ManagerSettings struct { - RegistrationToken string - Managers []*ManagerSettingsView + Managers []*ManagerSettingsView } // ManagerSettingsView contains fields shown on Manager settings pages. @@ -88,23 +91,24 @@ type ManagerSettingsView struct { BoundCodespaces int64 } -// ListManagerSettings returns the current token row and Manager summaries for one settings page. +// ListManagerSettings returns Manager summaries for one settings page. func ListManagerSettings(ctx context.Context, opts ManagerSettingsOptions) (*ManagerSettings, error) { if err := validateManagerSettingsScope(ctx, opts); err != nil { return nil, err } - result := &ManagerSettings{} - token, err := GetOrCreateRegistrationToken(ctx, opts) - if err != nil { + var managers []*codespace_model.Manager + query := db.GetEngine(ctx) + if opts.Scope != ManagerSettingsScopeSite { + query = query.Where("user_id = ?", opts.UserID) + } + if err := query.Asc("user_id", "id").Find(&managers); err != nil { return nil, err } - result.RegistrationToken = token - managers, err := listSettingsManagers(ctx, opts) + views, err := settingsManagerViews(ctx, managers, opts.UserID) if err != nil { return nil, err } - result.Managers = managers - return result, nil + return &ManagerSettings{Managers: views}, nil } // GetManagerDetail returns one Manager only when it belongs to the requested settings scope. @@ -142,66 +146,39 @@ func GetManagerDetail(ctx context.Context, opts ManagerDetailOptions) (*ManagerD return &ManagerDetail{Manager: views[0], Codespaces: list.Rows, Total: list.Total}, nil } -// GetOrCreateRegistrationToken returns or creates the current site or personal token. -func GetOrCreateRegistrationToken(ctx context.Context, opts ManagerSettingsOptions) (string, error) { +// CreateManager creates a Manager identity and returns its secret once. +func CreateManager(ctx context.Context, opts ManagerSettingsOptions) (*CreateManagerResult, error) { if err := validateManagerSettingsScope(ctx, opts); err != nil { - return "", err + return nil, err } - userID := registrationTokenUserID(opts) - var tokenValue string - err := globallock.LockAndDo(ctx, codespaceUserRelationLockKey(userID), func(ctx context.Context) error { - return db.WithTx(ctx, func(ctx context.Context) error { - if err := validateManagerSettingsUserInTx(ctx, opts); err != nil { - return err - } - token, err := loadRegistrationTokenByUser(ctx, userID) - if err != nil { - return err - } - if token != nil { - tokenValue = token.Token - return nil - } - tokenValue = newRegistrationToken() - _, err = db.GetEngine(ctx).Insert(&codespace_model.ManagerToken{ - UserID: userID, - Token: tokenValue, - }) - return err - }) - }) - return tokenValue, err -} - -// ResetRegistrationToken replaces the current site or personal token in place. -func ResetRegistrationToken(ctx context.Context, opts ManagerSettingsOptions) (string, error) { - if err := validateManagerSettingsScope(ctx, opts); err != nil { - return "", err + userID := opts.UserID + if opts.Scope == ManagerSettingsScopeSite { + userID = 0 } - userID := registrationTokenUserID(opts) - var tokenValue string + result := new(CreateManagerResult) err := globallock.LockAndDo(ctx, codespaceUserRelationLockKey(userID), func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error { - if err := validateManagerSettingsUserInTx(ctx, opts); err != nil { + if err := validateManagerSettingsScope(ctx, opts); err != nil { return err } - tokenValue = newRegistrationToken() - token, err := loadRegistrationTokenByUser(ctx, userID) - if err != nil { - return err + manager := &codespace_model.Manager{ + UserID: userID, + RuntimeState: codespace_model.ManagerRuntimeStateRecovering, + TagsJSON: "[]", + CreatedUnix: time.Now().Unix(), } - if token == nil { - _, err = db.GetEngine(ctx).Insert(&codespace_model.ManagerToken{ - UserID: userID, - Token: tokenValue, - }) + result.Secret = manager.GenerateManagerSecret() + if _, err := db.GetEngine(ctx).Insert(manager); err != nil { return err } - _, err = db.GetEngine(ctx).Where("user_id = ?", userID).Cols("token").Update(&codespace_model.ManagerToken{Token: tokenValue}) - return err + result.ManagerID = manager.ID + return nil }) }) - return tokenValue, err + if err != nil { + return nil, err + } + return result, nil } // DeleteManager removes one Manager identity and all Gitea records bound to it. @@ -256,15 +233,19 @@ func deleteManagerIdentityLocked(ctx context.Context, managerID int64, batchSize } } for { - codespaceUUIDs, err := listManagerCodespaceUUIDs(ctx, managerID, batchSize) - if err != nil { + var rows []*codespace_model.Codespace + if err := db.GetEngine(ctx). + Where("manager_id = ?", managerID). + Asc("id"). + Limit(batchSize). + Find(&rows); err != nil { return err } - if len(codespaceUUIDs) == 0 { + if len(rows) == 0 { break } - for _, codespaceUUID := range codespaceUUIDs { - if err := deleteManagerCodespace(ctx, managerID, codespaceUUID); err != nil { + for _, row := range rows { + if err := deleteManagerCodespace(ctx, managerID, row.UUID); err != nil { return err } } @@ -299,18 +280,6 @@ func deleteManagerIdentityLocked(ctx context.Context, managerID int64, batchSize }) } -func listSettingsManagers(ctx context.Context, opts ManagerSettingsOptions) ([]*ManagerSettingsView, error) { - var managers []*codespace_model.Manager - query := db.GetEngine(ctx) - if opts.Scope != ManagerSettingsScopeSite { - query = query.Where("user_id = ?", opts.UserID) - } - if err := query.Asc("user_id", "id").Find(&managers); err != nil { - return nil, err - } - return settingsManagerViews(ctx, managers, opts.UserID) -} - func settingsManagerViews(ctx context.Context, managers []*codespace_model.Manager, scopeUserID int64) ([]*ManagerSettingsView, error) { result := make([]*ManagerSettingsView, 0, len(managers)) if len(managers) == 0 { @@ -461,22 +430,6 @@ func findEnvironmentDescriptionConflicts(ctx context.Context, targetEnvironments return conflicts, nil } -func loadRegistrationTokenByUser(ctx context.Context, userID int64) (*codespace_model.ManagerToken, error) { - token := new(codespace_model.ManagerToken) - has, err := db.GetEngine(ctx).Where("user_id = ?", userID).Get(token) - if err != nil || !has { - return nil, err - } - return token, nil -} - -func registrationTokenUserID(opts ManagerSettingsOptions) int64 { - if opts.Scope == ManagerSettingsScopeSite { - return 0 - } - return opts.UserID -} - func validateManagerSettingsScope(ctx context.Context, opts ManagerSettingsOptions) error { switch opts.Scope { case ManagerSettingsScopeSite: @@ -488,41 +441,19 @@ func validateManagerSettingsScope(ctx context.Context, opts ManagerSettingsOptio if opts.UserID <= 0 { return errors.New("user_id must be positive") } - return validateManagerSettingsUser(ctx, opts) + user, err := user_model.GetUserByID(ctx, opts.UserID) + if err != nil { + return err + } + if user.Type != user_model.UserTypeIndividual { + return errors.New("user is not an individual") + } + return nil default: return fmt.Errorf("unsupported manager settings scope %q", opts.Scope) } } -func validateManagerSettingsUser(ctx context.Context, opts ManagerSettingsOptions) error { - user, err := user_model.GetUserByID(ctx, opts.UserID) - if err != nil { - return err - } - return validateManagerSettingsUserType(user) -} - -func validateManagerSettingsUserInTx(ctx context.Context, opts ManagerSettingsOptions) error { - if opts.Scope == ManagerSettingsScopeSite { - return nil - } - user, err := user_model.GetUserByID(ctx, opts.UserID) - if err != nil { - return err - } - return validateManagerSettingsUserType(user) -} - -func validateManagerSettingsUserType(user *user_model.User) error { - if user == nil { - return errors.New("user is required") - } - if user.Type != user_model.UserTypeIndividual { - return errors.New("user is not an individual") - } - return nil -} - func loadSettingsManager(ctx context.Context, managerID int64) (*codespace_model.Manager, error) { manager := new(codespace_model.Manager) has, err := db.GetEngine(ctx).ID(managerID).Get(manager) @@ -546,22 +477,6 @@ func managerInSettingsScope(manager *codespace_model.Manager, scope string, user } } -func listManagerCodespaceUUIDs(ctx context.Context, managerID int64, limit int) ([]string, error) { - var rows []*codespace_model.Codespace - if err := db.GetEngine(ctx). - Where("manager_id = ?", managerID). - Asc("id"). - Limit(limit). - Find(&rows); err != nil { - return nil, err - } - result := make([]string, 0, len(rows)) - for _, row := range rows { - result = append(result, row.UUID) - } - return result, nil -} - func deleteManagerCodespace(ctx context.Context, managerID int64, codespaceUUID string) error { return globallock.LockAndDo(ctx, codespaceStateLockKey(codespaceUUID), func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error { @@ -574,7 +489,3 @@ func deleteManagerCodespace(ctx context.Context, managerID int64, codespaceUUID }) }) } - -func newRegistrationToken() string { - return strings.ToLower(hex.EncodeToString(util.CryptoRandomBytes(32))) -} diff --git a/services/codespace/settings_test.go b/services/codespace/settings_test.go index 6d3c07504fdf1..0368958affb5e 100644 --- a/services/codespace/settings_test.go +++ b/services/codespace/settings_test.go @@ -14,67 +14,26 @@ import ( "github.com/stretchr/testify/require" ) -func TestRegistrationTokenSettingsLifecycle(t *testing.T) { +func TestCreateManagerReturnsOneTimeSecret(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) opts := ManagerSettingsOptions{Scope: ManagerSettingsScopeUser, UserID: 1} - settings, err := ListManagerSettings(t.Context(), opts) + result, err := CreateManager(t.Context(), opts) require.NoError(t, err) - require.Len(t, settings.RegistrationToken, 64) - - token, err := GetOrCreateRegistrationToken(t.Context(), opts) - require.NoError(t, err) - assert.Equal(t, settings.RegistrationToken, token) - - sameToken, err := GetOrCreateRegistrationToken(t.Context(), opts) - require.NoError(t, err) - assert.Equal(t, token, sameToken) - - resetToken, err := ResetRegistrationToken(t.Context(), opts) + require.NotZero(t, result.ManagerID) + require.NotEmpty(t, result.Secret) + manager, err := AuthenticateManager(t.Context(), result.ManagerID, result.Secret) require.NoError(t, err) - require.Len(t, resetToken, 64) - assert.NotEqual(t, token, resetToken) - - assertServiceNotExists(t, new(codespace_model.ManagerToken), "token = ?", token) - assertServiceExists(t, new(codespace_model.ManagerToken), "user_id = ? AND token = ?", 1, resetToken) -} - -func TestRegisterManagerUsesCurrentTokenAndKeepsSecretAfterReset(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - opts := ManagerSettingsOptions{Scope: ManagerSettingsScopeSite} - token, err := GetOrCreateRegistrationToken(t.Context(), opts) - require.NoError(t, err) - - manager, secret, err := RegisterManager(t.Context(), token) - require.NoError(t, err) - assert.EqualValues(t, 0, manager.UserID) - _, err = AuthenticateManager(t.Context(), manager.ID, secret) - require.NoError(t, err) - - resetToken, err := ResetRegistrationToken(t.Context(), opts) - require.NoError(t, err) - _, err = AuthenticateManager(t.Context(), manager.ID, secret) - require.NoError(t, err) - _, _, err = RegisterManager(t.Context(), token) - require.Error(t, err) - - resetManager, _, err := RegisterManager(t.Context(), resetToken) - require.NoError(t, err) - assert.NotEqual(t, manager.ID, resetManager.ID) + assert.EqualValues(t, 1, manager.UserID) + assert.Equal(t, codespace_model.ManagerRuntimeStateRecovering, manager.RuntimeState) } func TestListManagerSettingsScopesAndDeleteManager(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) - userToken, err := GetOrCreateRegistrationToken(t.Context(), ManagerSettingsOptions{ - Scope: ManagerSettingsScopeUser, - UserID: 1, - }) - require.NoError(t, err) globalManager := insertServiceManager(t) globalManager.TagsJSON = `[{"tag":"default","description":"Site environment"}]` - _, err = db.GetEngine(t.Context()).ID(globalManager.ID).Cols("tags_json").Update(globalManager) + _, err := db.GetEngine(t.Context()).ID(globalManager.ID).Cols("tags_json").Update(globalManager) require.NoError(t, err) userManager := insertServiceManager(t) userManager.UserID = 1 @@ -174,7 +133,6 @@ func TestListManagerSettingsScopesAndDeleteManager(t *testing.T) { assertServiceNotExists(t, new(codespace_model.Codespace), "uuid = ?", codespaceUUID) assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) - assertServiceExists(t, new(codespace_model.ManagerToken), "user_id = ? AND token = ?", 1, userToken) } func TestPersonalManagerDeleteRejectsForeignBindingBeforeCleanup(t *testing.T) { @@ -200,27 +158,13 @@ func TestPersonalManagerDeleteRejectsForeignBindingBeforeCleanup(t *testing.T) { func TestManagerSettingsRequireIndividualUser(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) - _, err := GetOrCreateRegistrationToken(t.Context(), ManagerSettingsOptions{ + _, err := CreateManager(t.Context(), ManagerSettingsOptions{ Scope: ManagerSettingsScopeUser, UserID: 3, }) require.ErrorContains(t, err, "not an individual") } -func TestRegisterManagerRejectsOrganizationToken(t *testing.T) { - require.NoError(t, unittest.PrepareTestDatabase()) - - token := &codespace_model.ManagerToken{ - Token: "organization-registration-token", - UserID: 3, - } - require.NoError(t, db.Insert(t.Context(), token)) - - _, _, err := RegisterManager(t.Context(), token.Token) - require.ErrorIs(t, err, ErrRegistrationUnauthenticated) - assertServiceNotExists(t, new(codespace_model.Manager), "user_id = ?", token.UserID) -} - func insertSettingsManagerAddress(t *testing.T, managerID int64, kind, address string) { t.Helper() require.NoError(t, db.Insert(t.Context(), &codespace_model.ManagerAddress{ diff --git a/services/codespace/user_delete.go b/services/codespace/user_delete.go index d2e3377f73e60..a474558733491 100644 --- a/services/codespace/user_delete.go +++ b/services/codespace/user_delete.go @@ -15,7 +15,7 @@ import ( const userDeleteBatchSize = 100 -// DeleteUserResources removes Codespace resources created or registered by one user. +// DeleteUserResources removes Codespace resources owned by one user. func DeleteUserResources(ctx context.Context, userID int64) error { if userID <= 0 { return errors.New("user_id must be positive") @@ -70,9 +70,6 @@ func deleteUserResourcesLocked(ctx context.Context, userID int64) error { if _, err := db.GetEngine(ctx).Where("user_id = ?", userID).Delete(new(codespace_model.PermissionAuthorization)); err != nil { return err } - if _, err := db.GetEngine(ctx).Where("user_id = ?", userID).Delete(new(codespace_model.ManagerToken)); err != nil { - return err - } hasManager, err := db.GetEngine(ctx).Where("user_id = ?", userID).Exist(new(codespace_model.Manager)) if err != nil { return err diff --git a/services/codespace/user_delete_test.go b/services/codespace/user_delete_test.go index 469f7d26b2f9e..81c16351b74c4 100644 --- a/services/codespace/user_delete_test.go +++ b/services/codespace/user_delete_test.go @@ -19,17 +19,9 @@ func TestDeleteUserResourcesOnlyCleansPersonalResources(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) userID := int64(2) - userToken, err := GetOrCreateRegistrationToken(t.Context(), ManagerSettingsOptions{ - Scope: ManagerSettingsScopeUser, - UserID: userID, - }) - require.NoError(t, err) - globalToken, err := GetOrCreateRegistrationToken(t.Context(), ManagerSettingsOptions{Scope: ManagerSettingsScopeSite}) - require.NoError(t, err) - userManager := insertServiceManager(t) userManager.UserID = userID - _, err = db.GetEngine(t.Context()).ID(userManager.ID).Cols("user_id").Update(userManager) + _, err := db.GetEngine(t.Context()).ID(userManager.ID).Cols("user_id").Update(userManager) require.NoError(t, err) insertSettingsManagerAddress(t, userManager.ID, codespace_model.ManagerAddressGateway, "https://user-delete.example.com") @@ -70,8 +62,6 @@ func TestDeleteUserResourcesOnlyCleansPersonalResources(t *testing.T) { assertServiceNotExists(t, new(codespace_model.ManagerAddress), "manager_id = ?", userManager.ID) assertServiceNotExists(t, new(codespace_model.Codespace), "uuid = ?", ownedUUID) assertServiceExists(t, new(codespace_model.Codespace), "uuid = ?", repositoryOwnedUUID) - assertServiceNotExists(t, new(codespace_model.ManagerToken), "user_id = ? AND token = ?", userID, userToken) - assertServiceExists(t, new(codespace_model.ManagerToken), "user_id = ? AND token = ?", 0, globalToken) assertServiceNotExists(t, new(codespace_model.PermissionAuthorization), "id = ?", authorization.ID) assertServiceNotExists(t, new(codespace_model.PermissionRepository), "authorization_id = ? AND target_repo_id = ? AND unit_type = ?", rule.AuthorizationID, rule.TargetRepoID, rule.UnitType) assertServiceNotExists(t, new(codespace_model.UserSecret), "id = ?", secret.ID) diff --git a/services/codespace/view.go b/services/codespace/view.go index 9a2bcbfc145d6..a908d65f7940c 100644 --- a/services/codespace/view.go +++ b/services/codespace/view.go @@ -73,8 +73,8 @@ type CreatorListOptions struct { // CreatorDetailOptions selects one creator-owned Codespace for a detail page. type CreatorDetailOptions struct { - UserID int64 - CodespaceUUID string + UserID int64 + CodespaceID int64 } // CreatorCodespaceList contains rows for a creator list page. @@ -85,6 +85,7 @@ type CreatorCodespaceList struct { // CreatorCodespaceView contains the server-authoritative presentation state. type CreatorCodespaceView struct { + ID int64 UUID string ShortUUID string RepoID int64 @@ -241,11 +242,11 @@ func GetCreatorCodespace(ctx context.Context, opts CreatorDetailOptions) (*Creat if opts.UserID <= 0 { return nil, errors.New("user_id must be positive") } - if err := codespace_model.ValidateUUID(opts.CodespaceUUID); err != nil { - return nil, err + if opts.CodespaceID <= 0 { + return nil, errors.New("codespace_id must be positive") } codespace := new(codespace_model.Codespace) - has, err := db.GetEngine(ctx).Where("uuid = ?", opts.CodespaceUUID).Get(codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceID).Get(codespace) if err != nil { return nil, err } @@ -270,6 +271,7 @@ func creatorCodespaceView(ctx context.Context, codespace *codespace_model.Codesp } } view := &CreatorCodespaceView{ + ID: codespace.ID, UUID: codespace.UUID, ShortUUID: shortCodespaceUUID(codespace.UUID), RepoID: codespace.RepoID, @@ -434,7 +436,7 @@ func runningDisplayStatus(ctx context.Context, codespace *codespace_model.Codesp view.Workspace = &CreatorEndpointView{ EndpointID: workspaceEndpointID, Label: workspaceEndpointLabel, - OpenPath: codespaceDetailPath(codespace.UUID) + "/open", + OpenPath: codespaceDetailPath(codespace.ID) + "/open", } continue } @@ -451,7 +453,7 @@ func runningDisplayStatus(ctx context.Context, codespace *codespace_model.Codesp Label: endpoint.Label, Port: uint16(port), Public: endpoint.Public, - OpenPath: codespaceDetailPath(codespace.UUID) + "/open/" + endpoint.EndpointID, + OpenPath: codespaceDetailPath(codespace.ID) + "/open/" + endpoint.EndpointID, }) } if view.Workspace == nil { @@ -567,6 +569,6 @@ func statusSummary(displayStatus string) string { } } -func codespaceDetailPath(codespaceUUID string) string { - return "/-/codespaces/" + codespaceUUID +func codespaceDetailPath(codespaceID int64) string { + return "/-/codespaces/" + strconv.FormatInt(codespaceID, 10) } diff --git a/services/codespace/view_test.go b/services/codespace/view_test.go index 2a244f541e539..7c0d88c8dcc6c 100644 --- a/services/codespace/view_test.go +++ b/services/codespace/view_test.go @@ -4,6 +4,7 @@ package codespace import ( + "strconv" "testing" "time" @@ -44,7 +45,7 @@ func TestListCreatorCodespacesShowsRunningWorkspace(t *testing.T) { assert.NotEmpty(t, row.CommitLink) assert.Equal(t, refreshStableMilliseconds, row.RefreshAfterMillis) require.NotNil(t, row.Workspace) - assert.Equal(t, "/-/codespaces/"+codespaceUUID+"/open", row.Workspace.OpenPath) + assert.Equal(t, "/-/codespaces/"+strconv.FormatInt(row.ID, 10)+"/open", row.Workspace.OpenPath) assert.Empty(t, row.Endpoints) assert.Nil(t, row.ResourceUsage) assert.Nil(t, row.SSH) @@ -231,7 +232,7 @@ func TestGetCreatorCodespaceKeepsQueuedIdleStopInteractive(t *testing.T) { {"endpoint_id": "public-app", "label": "Public app", "public": true}, }))) - view, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceUUID: codespaceUUID}) + view, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, codespaceUUID)}) require.NoError(t, err) assert.Equal(t, DisplayRunning, view.DisplayStatus) @@ -261,7 +262,7 @@ func TestGetCreatorCodespaceShowsTransitionsAndPermissions(t *testing.T) { OperationStatus: codespace_model.OperationStatusQueued, }) - view, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceUUID: codespaceUUID}) + view, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, codespaceUUID)}) require.NoError(t, err) assert.Equal(t, DisplayQueued, view.DisplayStatus) assert.Equal(t, DetailModeLogs, view.DetailMode) @@ -269,7 +270,7 @@ func TestGetCreatorCodespaceShowsTransitionsAndPermissions(t *testing.T) { assert.False(t, view.CanOpen) assert.True(t, view.CanDelete) - _, err = GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 2, CodespaceUUID: codespaceUUID}) + _, err = GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 2, CodespaceID: codespaceIDByUUID(t, codespaceUUID)}) require.ErrorIs(t, err, ErrViewPermissionDenied) } @@ -290,7 +291,7 @@ func TestGetCreatorCodespaceShowsCurrentBootStage(t *testing.T) { entry.Metadata.Boot.Stage = bootStagePrepareWorkspace require.NoError(t, putRuntimeMetadataEntry(codespaceUUID, entry)) - view, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceUUID: codespaceUUID}) + view, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, codespaceUUID)}) require.NoError(t, err) assert.Equal(t, DisplayBooting, view.DisplayStatus) assert.Equal(t, DetailModeLogs, view.DetailMode) @@ -307,7 +308,7 @@ func TestStoppedCreatorCodespaceResumeRequiresOnlineManager(t *testing.T) { Status: codespace_model.StatusStopped, }) - offlineView, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceUUID: offlineUUID}) + offlineView, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, offlineUUID)}) require.NoError(t, err) assert.Equal(t, DisplayStopped, offlineView.DisplayStatus) assert.False(t, offlineView.CanResume) @@ -319,7 +320,7 @@ func TestStoppedCreatorCodespaceResumeRequiresOnlineManager(t *testing.T) { Status: codespace_model.StatusStopped, }) - onlineView, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceUUID: onlineUUID}) + onlineView, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, onlineUUID)}) require.NoError(t, err) assert.Equal(t, DisplayStopped, onlineView.DisplayStatus) assert.True(t, onlineView.CanResume) @@ -328,7 +329,7 @@ func TestStoppedCreatorCodespaceResumeRequiresOnlineManager(t *testing.T) { RuntimeState: codespace_model.ManagerRuntimeStateRecovering, }) require.NoError(t, err) - recoveringView, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceUUID: onlineUUID}) + recoveringView, err := GetCreatorCodespace(t.Context(), CreatorDetailOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, onlineUUID)}) require.NoError(t, err) assert.False(t, recoveringView.CanResume) } diff --git a/services/user/user_test.go b/services/user/user_test.go index 036482c7708b9..3d330e1608325 100644 --- a/services/user/user_test.go +++ b/services/user/user_test.go @@ -158,10 +158,6 @@ func TestDeleteUserCleansCodespaceOwnerResources(t *testing.T) { } require.NoError(t, user_model.CreateUser(t.Context(), user, &user_model.Meta{})) manager := insertUserTestCodespaceManager(t, user.ID) - require.NoError(t, db.Insert(t.Context(), &codespace_model.ManagerToken{ - UserID: user.ID, - Token: "user-delete-codespace-token", - })) codespaceUUID := "71717171-7171-4171-8171-717171717171" codespace := &codespace_model.Codespace{ UUID: codespaceUUID, @@ -191,7 +187,6 @@ func TestDeleteUserCleansCodespaceOwnerResources(t *testing.T) { unittest.AssertNotExistsBean(t, &user_model.User{ID: user.ID}) assertUserTestNotExists(t, new(codespace_model.Manager), "id = ?", manager.ID) - assertUserTestNotExists(t, new(codespace_model.ManagerToken), "user_id = ?", user.ID) assertUserTestNotExists(t, new(codespace_model.Codespace), "uuid = ?", codespaceUUID) assertUserTestNotExists(t, new(codespace_model.GiteaToken), "codespace_id = ?", codespace.ID) } diff --git a/templates/codespace/detail.tmpl b/templates/codespace/detail.tmpl index 60fc179a43510..7991c6986585a 100644 --- a/templates/codespace/detail.tmpl +++ b/templates/codespace/detail.tmpl @@ -17,12 +17,12 @@
{{ctx.Locale.Tr "codespace.no_log_lines"}}
diff --git a/templates/codespace/list_rows.tmpl b/templates/codespace/list_rows.tmpl index 086d3407a4465..9826ae8d5dbd4 100644 --- a/templates/codespace/list_rows.tmpl +++ b/templates/codespace/list_rows.tmpl @@ -6,7 +6,7 @@
@@ -25,29 +25,29 @@ data-modal-form.url="{{AppSubUrl}}{{.Workspace.OpenPath}}" data-modal-form.target="_blank" data-modal-endpoint-label="{{ctx.Locale.Tr "codespace.workspace"}}" data-modal-access-kind="{{ctx.Locale.Tr "codespace.authenticated_endpoint"}}">{{svg "octicon-terminal" 16}} {{else}} - {{svg "octicon-eye" 16}} + {{svg "octicon-eye" 16}} {{end}} {{if or .CanResume (and .CanContinue (not .CanOpen)) .CanStop .CanConfigureAutoStop .CanDelete}}{{end}} {{if .CanResume}} -
+ {{$.CsrfTokenHtml}}
{{end}} {{if and .CanContinue (not .CanOpen)}} -
+ {{$.CsrfTokenHtml}}
{{end}} {{if .CanStop}} - + {{end}} {{if .CanConfigureAutoStop}} {{end}} {{if .CanDelete}} - {{end}} diff --git a/templates/codespace/manager_codespaces.tmpl b/templates/codespace/manager_codespaces.tmpl index 58e46eb946bb6..7883305c2379c 100644 --- a/templates/codespace/manager_codespaces.tmpl +++ b/templates/codespace/manager_codespaces.tmpl @@ -18,7 +18,7 @@ {{if $.IsSiteManagerSettings}} {{.ShortUUID}} {{else}} - {{.ShortUUID}} + {{.ShortUUID}} {{end}} @@ -36,7 +36,7 @@ {{if .CanForceDelete}}
{{$.CsrfTokenHtml}}
{{end}}
{{else}} - {{svg "octicon-arrow-right"}} + {{svg "octicon-arrow-right"}} {{end}} diff --git a/templates/codespace/settings_content.tmpl b/templates/codespace/settings_content.tmpl index 95154d5656ef3..b8f71baf05f6f 100644 --- a/templates/codespace/settings_content.tmpl +++ b/templates/codespace/settings_content.tmpl @@ -2,29 +2,23 @@

{{ctx.Locale.Tr "codespace.managers"}} ({{ctx.Locale.Tr "admin.total" .ManagerTotal}})
- +

+ {{if .NewManagerSecret}} +
+
{{ctx.Locale.Tr "codespace.manager_secret_created" .NewManagerID}}
+
+ + -
- + {{end}} {{if .ManagerSettings.Managers}}
diff --git a/templates/codespace/state.tmpl b/templates/codespace/state.tmpl index 7ae802fe3dbf5..c1502517dc288 100644 --- a/templates/codespace/state.tmpl +++ b/templates/codespace/state.tmpl @@ -1,5 +1,5 @@ {{$cs := .Codespace}} -
+
@@ -25,19 +25,19 @@ {{if $hasOpen}} {{else if $cs.CanResume}} -
{{$.CsrfTokenHtml}} +
{{$.CsrfTokenHtml}} {{else if $continueIsPrimary}} -
{{$.CsrfTokenHtml}} +
{{$.CsrfTokenHtml}} {{end}} - {{if and $cs.CanContinue (not $continueIsPrimary)}}{{end}} - {{if $cs.CanStop}}{{end}} + {{if and $cs.CanContinue (not $continueIsPrimary)}}{{end}} + {{if $cs.CanStop}}{{end}} {{if or $cs.CanConfigureAutoStop $cs.CanDelete}} {{end}} @@ -46,8 +46,8 @@
{{if eq .CodespaceTab "overview"}} diff --git a/templates/repo/codespace_panel.tmpl b/templates/repo/codespace_panel.tmpl index 7c080d941383e..cf93876933bea 100644 --- a/templates/repo/codespace_panel.tmpl +++ b/templates/repo/codespace_panel.tmpl @@ -19,7 +19,7 @@
{{ctx.Locale.Tr "codespace.recent"}}
{{range .Codespaces}} {{end}} diff --git a/tests/integration/codespace_test.go b/tests/integration/codespace_test.go index 9524c36622132..a177f22eb29d0 100644 --- a/tests/integration/codespace_test.go +++ b/tests/integration/codespace_test.go @@ -36,7 +36,7 @@ import ( ) func TestCodespaceRoutes(t *testing.T) { - onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + onGiteaRun(t, func(t *testing.T, _ *url.URL) { insertIntegrationDevContainerTemplate(t) MakeRequest(t, NewRequest(t, http.MethodGet, "/-/codespaces"), http.StatusSeeOther) @@ -68,8 +68,6 @@ func TestCodespaceRoutes(t *testing.T) { require.True(t, ruleExists) assert.Equal(t, perm.AccessModeRead, loadedRule.GrantedMode) loginUser(t, "user4").MakeRequest(t, NewRequestWithValues(t, http.MethodPost, "/user/settings/codespaces/permissions", permissionForm), http.StatusNotFound) - user2Session.MakeRequest(t, NewRequest(t, http.MethodPost, "/user/settings/codespaces/managers/reset_registration_token"), http.StatusOK) - manager := &codespace_model.Manager{ Name: "integration-manager", UserID: 2, @@ -88,6 +86,11 @@ func TestCodespaceRoutes(t *testing.T) { require.True(t, strings.HasPrefix(location, "/-/codespaces/")) user2Session.MakeRequest(t, NewRequest(t, http.MethodGet, location), http.StatusOK) loginUser(t, "user4").MakeRequest(t, NewRequest(t, http.MethodGet, location), http.StatusNotFound) + forceDeleteUUID := codespace_model.NewUUID() + insertIntegrationCodespace(t, 0, &codespace_model.Codespace{ + UUID: forceDeleteUUID, + Status: codespace_model.StatusFailed, + }) adminSession := loginUser(t, "user1") adminSession.MakeRequest(t, NewRequest(t, http.MethodGet, "/-/admin/codespaces/managers"), http.StatusOK) @@ -96,22 +99,11 @@ func TestCodespaceRoutes(t *testing.T) { user2Session.MakeRequest(t, NewRequest(t, http.MethodGet, "/-/admin/codespaces/managers"), http.StatusForbidden) user2Session.MakeRequest(t, NewRequest(t, http.MethodGet, "/org/org3/settings/codespaces"), http.StatusNotFound) - forceDeleteURL := "/-/admin/codespaces/managers/unassigned/" + strings.TrimPrefix(location, "/-/codespaces/") + "/force-delete" + forceDeleteURL := "/-/admin/codespaces/managers/unassigned/" + forceDeleteUUID + "/force-delete" adminSession.MakeRequest(t, NewRequest(t, http.MethodPost, forceDeleteURL), http.StatusSeeOther) adminSession.MakeRequest(t, NewRequestWithValues(t, http.MethodPost, forceDeleteURL, map[string]string{ "confirm": "force-delete", }), http.StatusSeeOther) - - client := codespacev1connect.NewManagerServiceClient( - http.DefaultClient, - strings.TrimRight(giteaURL.String(), "/")+"/api/codespace", - ) - _, err = client.RegisterManager(t.Context(), connect.NewRequest(&codespacev1.RegisterManagerRequest{ - ProtocolVersion: 0, - RegistrationToken: "missing", - })) - require.Error(t, err) - assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) }) } @@ -233,10 +225,11 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { user2Session := loginUser(t, "user2") created := createCodespaceFromRepository(t, user2Session, "/user2/repo1/codespaces", "branch", "master") - codespaceUUID := strings.TrimPrefix(created.Header().Get("Location"), "/-/codespaces/") - require.NoError(t, codespace_model.ValidateUUID(codespaceUUID)) + codespaceID := integrationCodespaceIDFromLocation(t, created.Header().Get("Location")) + codespacePath := "/-/codespaces/" + strconv.FormatInt(codespaceID, 10) - row := loadIntegrationCodespace(t, codespaceUUID) + row := loadIntegrationCodespaceByID(t, codespaceID) + require.Empty(t, row.UUID) require.Equal(t, codespace_model.StatusCreating, row.Status) require.Equal(t, codespace_model.OperationCreate, row.OperationType) require.Equal(t, codespace_model.OperationStatusQueued, row.OperationStatus) @@ -256,7 +249,17 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { })) require.NoError(t, err) require.Len(t, fetched.Msg.GetOperations(), 1) - assert.NotNil(t, fetched.Msg.GetOperations()[0].GetCreate()) + operation := fetched.Msg.GetOperations()[0] + assert.NotNil(t, operation.GetCreate()) + codespaceUUID := codespace_model.NewUUID() + bound, err := client.BindRuntimeIdentity(t.Context(), codespaceManagerRequest(manager.ID, secret, &codespacev1.BindRuntimeIdentityRequest{ + ProtocolVersion: 1, + CodespaceId: operation.GetCodespaceId(), + OperationRversion: operation.GetOperationRversion(), + RuntimeUuid: codespaceUUID, + })) + require.NoError(t, err) + require.Equal(t, codespaceUUID, bound.Msg.GetRuntimeUuid()) _, err = client.FinalizeOperation(t.Context(), codespaceManagerRequest(manager.ID, secret, createFinalRequest(codespaceUUID, 1, codespacev1.OperationType_OPERATION_TYPE_CREATE, codespacev1.FinalStatus_FINAL_STATUS_DONE))) require.Error(t, err) @@ -265,7 +268,7 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { tokenResponse, err := client.RequestRuntimeAccess(t.Context(), codespaceManagerRequest(manager.ID, secret, &codespacev1.RequestRuntimeAccessRequest{ ProtocolVersion: 1, - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, OperationRversion: 1, GitSshKey: &codespacev1.RuntimeGitSSHKey{PublicKey: integrationGitSSHPublicKey(t)}, })) @@ -279,7 +282,7 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { _, err = client.ReportRuntimeMetadata(t.Context(), codespaceManagerRequest(manager.ID, secret, &codespacev1.ReportRuntimeMetadataRequest{ ProtocolVersion: 1, - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, MetadataGeneration: 1, Metadata: integrationRuntimeMetadata(1), })) @@ -293,20 +296,20 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { assert.Empty(t, row.OperationType) assertIntegrationExists(t, new(codespace_model.GiteaToken), "codespace_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) - autoStopResponse := user2Session.MakeRequest(t, NewRequestWithValues(t, http.MethodPost, "/-/codespaces/"+codespaceUUID+"/auto-stop", map[string]string{ + autoStopResponse := user2Session.MakeRequest(t, NewRequestWithValues(t, http.MethodPost, codespacePath+"/auto-stop", map[string]string{ "mode": "custom", "timeout_value": "30", "timeout_unit": "minutes", "return_to": "detail", }), http.StatusSeeOther) - assert.Equal(t, "/-/codespaces/"+codespaceUUID, autoStopResponse.Header().Get("Location")) + assert.Equal(t, codespacePath, autoStopResponse.Header().Get("Location")) row = loadIntegrationCodespace(t, codespaceUUID) assert.Equal(t, codespace_model.AutoStopModeCustom, row.AutoStopMode) assert.EqualValues(t, 30*60, row.AutoStopTimeoutSeconds) idleStop, err := client.RequestIdleStop(t.Context(), codespaceManagerRequest(manager.ID, secret, &codespacev1.RequestIdleStopRequest{ ProtocolVersion: 1, - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, ObservedSettings: &codespacev1.EffectiveCodespaceRuntimeSettings{ AutoStopEnabled: true, IdleTimeoutSeconds: int64(setting.Codespace.AutoStopDefaultTimeout / time.Second), @@ -317,13 +320,13 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { require.NotNil(t, idleStop.Msg.GetPending()) assert.EqualValues(t, 2, idleStop.Msg.GetPending().GetOperationRversion()) - user2Session.MakeRequest(t, NewRequest(t, http.MethodPost, "/-/codespaces/"+codespaceUUID+"/continue"), http.StatusSeeOther) + user2Session.MakeRequest(t, NewRequest(t, http.MethodPost, codespacePath+"/continue"), http.StatusSeeOther) row = loadIntegrationCodespace(t, codespaceUUID) assert.Equal(t, codespace_model.StatusRunning, row.Status) assert.Empty(t, row.OperationType) assert.EqualValues(t, 1, row.InteractionGeneration) - user2Session.MakeRequest(t, NewRequest(t, http.MethodPost, "/-/codespaces/"+codespaceUUID+"/stop"), http.StatusSeeOther) + user2Session.MakeRequest(t, NewRequest(t, http.MethodPost, codespacePath+"/stop"), http.StatusSeeOther) fetched, err = client.FetchOperations(t.Context(), codespaceManagerRequest(manager.ID, secret, &codespacev1.FetchOperationsRequest{ ProtocolVersion: 1, CleanupCapacityAvailable: 1, @@ -340,7 +343,7 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { assert.Empty(t, row.OperationType) assertIntegrationNotExists(t, new(codespace_model.GiteaToken), "codespace_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) - user2Session.MakeRequest(t, NewRequest(t, http.MethodPost, "/-/codespaces/"+codespaceUUID+"/resume"), http.StatusSeeOther) + user2Session.MakeRequest(t, NewRequest(t, http.MethodPost, codespacePath+"/resume"), http.StatusSeeOther) row = loadIntegrationCodespace(t, codespaceUUID) assert.Equal(t, codespace_model.OperationResume, row.OperationType) assert.EqualValues(t, 2, row.InteractionGeneration) @@ -357,14 +360,14 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { resumeVersion := fetched.Msg.GetOperations()[0].GetOperationRversion() _, err = client.RequestRuntimeAccess(t.Context(), codespaceManagerRequest(manager.ID, secret, &codespacev1.RequestRuntimeAccessRequest{ ProtocolVersion: 1, - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, OperationRversion: resumeVersion, GitSshKey: &codespacev1.RuntimeGitSSHKey{PublicKey: integrationGitSSHPublicKey(t)}, })) require.NoError(t, err) _, err = client.ReportRuntimeMetadata(t.Context(), codespaceManagerRequest(manager.ID, secret, &codespacev1.ReportRuntimeMetadataRequest{ ProtocolVersion: 1, - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, MetadataGeneration: 2, Metadata: integrationRuntimeMetadata(resumeVersion), })) @@ -377,7 +380,7 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { idleStop, err = client.RequestIdleStop(t.Context(), codespaceManagerRequest(manager.ID, secret, &codespacev1.RequestIdleStopRequest{ ProtocolVersion: 1, - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, ObservedSettings: &codespacev1.EffectiveCodespaceRuntimeSettings{ AutoStopEnabled: true, IdleTimeoutSeconds: int64(setting.Codespace.AutoStopDefaultTimeout / time.Second), @@ -402,7 +405,7 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { assert.Empty(t, row.OperationType) assertIntegrationNotExists(t, new(codespace_model.GiteaToken), "codespace_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) - user2Session.MakeRequest(t, NewRequest(t, http.MethodPost, "/-/codespaces/"+codespaceUUID+"/resume"), http.StatusSeeOther) + user2Session.MakeRequest(t, NewRequest(t, http.MethodPost, codespacePath+"/resume"), http.StatusSeeOther) row = loadIntegrationCodespace(t, codespaceUUID) assert.Equal(t, codespace_model.OperationResume, row.OperationType) assert.EqualValues(t, 3, row.InteractionGeneration) @@ -418,14 +421,14 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { resumeVersion = fetched.Msg.GetOperations()[0].GetOperationRversion() _, err = client.RequestRuntimeAccess(t.Context(), codespaceManagerRequest(manager.ID, secret, &codespacev1.RequestRuntimeAccessRequest{ ProtocolVersion: 1, - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, OperationRversion: resumeVersion, GitSshKey: &codespacev1.RuntimeGitSSHKey{PublicKey: integrationGitSSHPublicKey(t)}, })) require.NoError(t, err) _, err = client.ReportRuntimeMetadata(t.Context(), codespaceManagerRequest(manager.ID, secret, &codespacev1.ReportRuntimeMetadataRequest{ ProtocolVersion: 1, - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, MetadataGeneration: 3, Metadata: integrationRuntimeMetadata(resumeVersion), })) @@ -435,7 +438,7 @@ func TestCodespaceLifecycleStateMachineIntegration(t *testing.T) { assert.False(t, finalResume.Msg.GetResourceAbsent()) assert.Equal(t, codespace_model.StatusRunning, loadIntegrationCodespace(t, codespaceUUID).Status) - user2Session.MakeRequest(t, NewRequestWithValues(t, http.MethodPost, "/-/codespaces/"+codespaceUUID+"/delete", map[string]string{ + user2Session.MakeRequest(t, NewRequestWithValues(t, http.MethodPost, codespacePath+"/delete", map[string]string{ "return_to": "/-/codespaces", }), http.StatusSeeOther) assertIntegrationNotExists(t, new(codespace_model.GiteaToken), "codespace_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) @@ -566,23 +569,23 @@ func TestCodespaceInventoryStateMachineIntegration(t *testing.T) { ProtocolVersion: 1, InventoryGeneration: 1, Instances: []*codespacev1.RuntimeInstanceRef{ - {CodespaceUuid: runningUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, - {CodespaceUuid: refetchUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 11}, - {CodespaceUuid: clearUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 13}, - {CodespaceUuid: reportStoppedUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED}, - {CodespaceUuid: reportFailedUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, - {CodespaceUuid: stopUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, - {CodespaceUuid: failedCleanupUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, - {CodespaceUuid: otherBindingUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, - {CodespaceUuid: unboundCreatingUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_CREATING}, - {CodespaceUuid: activeNoContextUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, - {CodespaceUuid: activeSameFailedUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED, ObservedOperationRversion: 21}, - {CodespaceUuid: absentUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, + {RuntimeUuid: runningUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, + {RuntimeUuid: refetchUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 11}, + {RuntimeUuid: clearUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 13}, + {RuntimeUuid: reportStoppedUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_STOPPED}, + {RuntimeUuid: reportFailedUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, + {RuntimeUuid: stopUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, + {RuntimeUuid: failedCleanupUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, + {RuntimeUuid: otherBindingUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, + {RuntimeUuid: unboundCreatingUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_CREATING}, + {RuntimeUuid: activeNoContextUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING}, + {RuntimeUuid: activeSameFailedUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED, ObservedOperationRversion: 21}, + {RuntimeUuid: absentUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_FAILED}, }, })) require.NoError(t, err) require.Len(t, inventory.Msg.GetResults(), 12) - assert.Equal(t, runningUUID, inventory.Msg.GetResults()[0].GetCodespaceUuid()) + assert.Equal(t, runningUUID, inventory.Msg.GetResults()[0].GetRuntimeUuid()) assert.NotNil(t, inventory.Msg.GetResults()[0].GetRuntimeSettings()) assert.Equal(t, codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_UNSPECIFIED, inventory.Msg.GetResults()[0].GetAction()) assert.EqualValues(t, 21, inventory.Msg.GetResults()[0].GetRuntimeSettings().GetInteractionGeneration()) @@ -612,7 +615,7 @@ func TestCodespaceInventoryStateMachineIntegration(t *testing.T) { ProtocolVersion: 1, InventoryGeneration: 2, Instances: []*codespacev1.RuntimeInstanceRef{{ - CodespaceUuid: runningUUID, + RuntimeUuid: runningUUID, RuntimeState: codespacev1.RuntimeState_RUNTIME_STATE_RUNNING, ObservedOperationRversion: 12, }}, @@ -758,6 +761,23 @@ func loadIntegrationCodespace(t *testing.T, codespaceUUID string) *codespace_mod return row } +func loadIntegrationCodespaceByID(t *testing.T, codespaceID int64) *codespace_model.Codespace { + t.Helper() + row := new(codespace_model.Codespace) + has, err := db.GetEngine(t.Context()).ID(codespaceID).Get(row) + require.NoError(t, err) + require.True(t, has) + return row +} + +func integrationCodespaceIDFromLocation(t *testing.T, location string) int64 { + t.Helper() + require.True(t, strings.HasPrefix(location, "/-/codespaces/")) + codespaceID, err := strconv.ParseInt(strings.TrimPrefix(location, "/-/codespaces/"), 10, 64) + require.NoError(t, err) + return codespaceID +} + func codespaceManagerRequest[T any](managerID int64, managerSecret string, message *T) *connect.Request[T] { request := connect.NewRequest(message) request.Header().Set("x-codespace-manager-id", strconv.FormatInt(managerID, 10)) @@ -768,7 +788,7 @@ func codespaceManagerRequest[T any](managerID int64, managerSecret string, messa func createFinalRequest(codespaceUUID string, operationRVersion int64, operationType codespacev1.OperationType, finalStatus codespacev1.FinalStatus) *codespacev1.FinalizeOperationRequest { return &codespacev1.FinalizeOperationRequest{ ProtocolVersion: 1, - CodespaceUuid: codespaceUUID, + RuntimeUuid: codespaceUUID, OperationRversion: operationRVersion, Status: finalStatus, OperationType: operationType, From 154c00513bbf0dd3fbd7f6e8f903140a7ee83619 Mon Sep 17 00:00:00 2001 From: dragon Date: Thu, 20 Aug 2026 10:35:16 +0800 Subject: [PATCH 14/16] enhance(codespace): require manager display names Assisted-by: Codex:gpt-5 --- options/locale/locale_en-US.json | 6 ++- routers/api/codespace/manager/manager.go | 1 - routers/api/codespace/manager/manager_test.go | 9 ++-- routers/web/codespace/settings.go | 13 +++-- services/codespace/manager.go | 11 +--- services/codespace/manager_test.go | 3 -- services/codespace/settings.go | 38 ++++++++++++-- services/codespace/settings_test.go | 28 +++++++++-- templates/codespace/settings_content.tmpl | 50 +++++++++++++------ web_src/js/features/common-page.ts | 3 +- web_src/js/features/comp/CodespaceSettings.ts | 5 ++ 11 files changed, 121 insertions(+), 46 deletions(-) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index f33b1af24da62..a17dc50ecc050 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3089,6 +3089,10 @@ "codespace.manager_status.recovering": "Recovering", "codespace.managers": "Codespace Managers", "codespace.memory": "Memory", + "codespace.manager_create": "Create Manager", + "codespace.manager_create_description": "Create a Manager identity and copy its one-time secret before configuring the Manager process.", + "codespace.manager_name": "Name", + "codespace.manager_name_invalid": "Manager name is required and must not contain control characters.", "codespace.new_manager": "New", "codespace.not_declared": "Not declared", "codespace.no_log_lines": "No log lines yet.", @@ -3125,7 +3129,7 @@ "codespace.recent": "Recent Codespaces", "codespace.recommended_secrets": "Recommended secrets", "codespace.recommended_secrets_description": "The selected Dev Container recommends these environment variables. Existing values remain hidden. You can allow this repository or provide an optional missing value before creation.", - "codespace.manager_secret_created": "Manager %d was created. Copy this secret now; it will not be shown again.", + "codespace.manager_secret_created": "Manager \"%s\" (ID %d) was created. Copy this secret now; it will not be shown again.", "codespace.repo_empty": "No codespaces for this repository.", "codespace.repository": "Repository", "codespace.repository_access": "Repository access", diff --git a/routers/api/codespace/manager/manager.go b/routers/api/codespace/manager/manager.go index 13e609aafc767..debc95c8b675e 100644 --- a/routers/api/codespace/manager/manager.go +++ b/routers/api/codespace/manager/manager.go @@ -44,7 +44,6 @@ func (s *Service) DeclareManager( GatewaySSHAddr: req.Msg.GetGatewaySshAddr(), Environments: req.Msg.GetEnvironments(), Version: req.Msg.GetVersion(), - Name: req.Msg.GetName(), RuntimeState: req.Msg.GetManagerRuntimeState(), GatewaySSHHostKeyAlgorithm: req.Msg.GetGatewaySshHostKeyAlgorithm(), GatewaySSHHostKeyFingerprintSHA256: req.Msg.GetGatewaySshHostKeyFingerprintSha256(), diff --git a/routers/api/codespace/manager/manager_test.go b/routers/api/codespace/manager/manager_test.go index 67916f136d3d5..9cd2e0945ade1 100644 --- a/routers/api/codespace/manager/manager_test.go +++ b/routers/api/codespace/manager/manager_test.go @@ -31,7 +31,10 @@ func TestManagerServiceProtocolAuthenticationAndDeclaration(t *testing.T) { client, cleanup := newManagerTestClient(t) defer cleanup() - created, err := codespace_service.CreateManager(t.Context(), codespace_service.ManagerSettingsOptions{Scope: codespace_service.ManagerSettingsScopeSite}) + created, err := codespace_service.CreateManager(t.Context(), codespace_service.CreateManagerOptions{ + ManagerSettingsOptions: codespace_service.ManagerSettingsOptions{Scope: codespace_service.ManagerSettingsScopeSite}, + Name: "Gitea Manager", + }) require.NoError(t, err) require.Positive(t, created.ManagerID) require.NotEmpty(t, created.Secret) @@ -42,7 +45,6 @@ func TestManagerServiceProtocolAuthenticationAndDeclaration(t *testing.T) { GatewaySshAddr: "WorkSpace.EXAMPLE.com:0022", Environments: []*codespacev1.EnvironmentTag{{Tag: "Default", Description: "Default environment"}, {Tag: "incus"}}, Version: " 0.1.0 ", - Name: " manager-one ", ManagerRuntimeState: codespacev1.ManagerRuntimeState_MANAGER_RUNTIME_STATE_ONLINE, GatewaySshHostKeyAlgorithm: " ssh-ed25519 ", GatewaySshHostKeyFingerprintSha256: " SHA256:test ", @@ -76,7 +78,7 @@ func TestManagerServiceProtocolAuthenticationAndDeclaration(t *testing.T) { has, err := db.GetEngine(t.Context()).ID(created.ManagerID).Get(manager) require.NoError(t, err) require.True(t, has) - assert.Equal(t, "manager-one", manager.Name) + assert.Equal(t, "Gitea Manager", manager.Name) assert.JSONEq(t, `[{"tag":"default","description":"Default environment"},{"tag":"incus"}]`, manager.TagsJSON) assert.Equal(t, "0.1.0", manager.Version) assert.Equal(t, "ssh-ed25519", manager.GatewaySSHHostKeyAlgorithm) @@ -417,7 +419,6 @@ func managerTestDeclaration(gatewayURL, gatewaySSHAddr string) *codespacev1.Decl GatewaySshAddr: gatewaySSHAddr, Environments: []*codespacev1.EnvironmentTag{{Tag: "default"}}, Version: "0.1.0", - Name: "manager", ManagerRuntimeState: codespacev1.ManagerRuntimeState_MANAGER_RUNTIME_STATE_ONLINE, GatewaySshHostKeyAlgorithm: "ssh-ed25519", GatewaySshHostKeyFingerprintSha256: "SHA256:test", diff --git a/routers/web/codespace/settings.go b/routers/web/codespace/settings.go index 7f41d105fb638..27b865854e6a9 100644 --- a/routers/web/codespace/settings.go +++ b/routers/web/codespace/settings.go @@ -313,9 +313,12 @@ func handleManagerDelete(ctx *context.Context, opts managerSettingsRenderOptions } func handleManagerSettingsCreateManager(ctx *context.Context, opts managerSettingsRenderOptions) { - result, err := codespace_service.CreateManager(ctx, codespace_service.ManagerSettingsOptions{ - Scope: opts.Scope, - UserID: opts.UserID, + result, err := codespace_service.CreateManager(ctx, codespace_service.CreateManagerOptions{ + ManagerSettingsOptions: codespace_service.ManagerSettingsOptions{ + Scope: opts.Scope, + UserID: opts.UserID, + }, + Name: ctx.FormString("name"), }) if err != nil { handleManagerSettingsActionError(ctx, opts.ActionBase, err) @@ -325,6 +328,7 @@ func handleManagerSettingsCreateManager(ctx *context.Context, opts managerSettin return } ctx.Data["NewManagerID"] = result.ManagerID + ctx.Data["NewManagerName"] = result.Name ctx.Data["NewManagerSecret"] = result.Secret ctx.HTML(http.StatusOK, opts.Template) } @@ -339,6 +343,9 @@ func handleManagerSettingsActionError(ctx *context.Context, redirectTo string, e case errors.Is(err, codespace_service.ErrManagerSettingsOwnershipConflict): ctx.Flash.Error(ctx.Tr("codespace.manager_ownership_conflict")) ctx.Redirect(redirectTo, http.StatusSeeOther) + case errors.Is(err, codespace_service.ErrManagerSettingsNameInvalid): + ctx.Flash.Error(ctx.Tr("codespace.manager_name_invalid")) + ctx.Redirect(redirectTo, http.StatusSeeOther) default: ctx.ServerError("CodespaceManagerSettingsAction", err) } diff --git a/services/codespace/manager.go b/services/codespace/manager.go index 260de278fbbe6..3866c705bd8c4 100644 --- a/services/codespace/manager.go +++ b/services/codespace/manager.go @@ -44,7 +44,6 @@ type DeclareManagerOptions struct { GatewaySSHAddr string Environments []*codespacev1.EnvironmentTag Version string - Name string RuntimeState codespacev1.ManagerRuntimeState GatewaySSHHostKeyAlgorithm string GatewaySSHHostKeyFingerprintSHA256 string @@ -160,7 +159,6 @@ func DeclareManager(ctx context.Context, manager *codespace_model.Manager, opts } now := time.Now().Unix() updates := &codespace_model.Manager{ - Name: opts.Name, TagsJSON: string(tagsJSON), RuntimeState: managerRuntimeStateName(opts.RuntimeState), LastOnlineUnix: now, @@ -170,7 +168,7 @@ func DeclareManager(ctx context.Context, manager *codespace_model.Manager, opts GatewaySSHHostKeyUpdatedUnix: opts.GatewaySSHHostKeyUpdatedUnix, } affected, err := db.GetEngine(ctx).ID(currentManager.ID).Cols( - "name", "tags_json", "runtime_state", "last_online_unix", "version", + "tags_json", "runtime_state", "last_online_unix", "version", "gateway_ssh_host_key_algorithm", "gateway_ssh_host_key_fingerprint_sha256", "gateway_ssh_host_key_updated_unix", ).Update(updates) if err != nil { @@ -195,13 +193,6 @@ func DeclareManager(ctx context.Context, manager *codespace_model.Manager, opts } func normalizeDeclareManagerOptions(opts DeclareManagerOptions) (DeclareManagerOptions, error) { - opts.Name = strings.TrimSpace(opts.Name) - if opts.Name == "" { - return opts, errors.New("manager name is required") - } - if len(opts.Name) > 255 { - return opts, errors.New("manager name is too long") - } opts.Version = strings.TrimSpace(opts.Version) if opts.Version == "" { return opts, errors.New("manager version is required") diff --git a/services/codespace/manager_test.go b/services/codespace/manager_test.go index ad6ea265c9034..fb7dd28f03c92 100644 --- a/services/codespace/manager_test.go +++ b/services/codespace/manager_test.go @@ -25,7 +25,6 @@ func TestNormalizeDeclareManagerOptions(t *testing.T) { opts := validDeclareManagerOptions() opts.GatewayURL = "https://WorkSpace.EXAMPLE.com:443/" opts.GatewaySSHAddr = "WorkSpace.EXAMPLE.com:0022" - opts.Name = " manager " opts.Version = " 1.0.0 " opts.GatewaySSHHostKeyAlgorithm = " ssh-ed25519 " opts.GatewaySSHHostKeyFingerprintSHA256 = " SHA256:abc " @@ -34,7 +33,6 @@ func TestNormalizeDeclareManagerOptions(t *testing.T) { require.NoError(t, err) assert.Equal(t, "https://workspace.example.com", normalized.GatewayURL) assert.Equal(t, "workspace.example.com:22", normalized.GatewaySSHAddr) - assert.Equal(t, "manager", normalized.Name) assert.Equal(t, "1.0.0", normalized.Version) assert.Equal(t, "ssh-ed25519", normalized.GatewaySSHHostKeyAlgorithm) assert.Equal(t, "SHA256:abc", normalized.GatewaySSHHostKeyFingerprintSHA256) @@ -408,7 +406,6 @@ func validDeclareManagerOptions() DeclareManagerOptions { GatewaySSHAddr: "workspace.example.com:22", Environments: []*codespacev1.EnvironmentTag{{Tag: "default"}}, Version: "1.0.0", - Name: "manager", RuntimeState: codespacev1.ManagerRuntimeState_MANAGER_RUNTIME_STATE_ONLINE, GatewaySSHHostKeyAlgorithm: "ssh-ed25519", GatewaySSHHostKeyFingerprintSHA256: "SHA256:abc", diff --git a/services/codespace/settings.go b/services/codespace/settings.go index 4937eb7c346e8..5c7bb67f897ef 100644 --- a/services/codespace/settings.go +++ b/services/codespace/settings.go @@ -7,7 +7,9 @@ import ( "context" "errors" "fmt" + "strings" "time" + "unicode" codespace_model "gitea.dev/models/codespace" "gitea.dev/models/db" @@ -29,6 +31,8 @@ var ( ErrManagerSettingsConfirmRequired = errors.New("codespace manager settings confirmation required") // ErrManagerSettingsOwnershipConflict is returned before personal deletion when a binding crosses the owner scope. ErrManagerSettingsOwnershipConflict = errors.New("codespace manager contains a Codespace outside the owner scope") + // ErrManagerSettingsNameInvalid is returned when a Manager display name cannot be stored. + ErrManagerSettingsNameInvalid = errors.New("codespace manager name is invalid") ) // ManagerSettingsOptions selects site-wide or personal Codespace settings. @@ -45,9 +49,16 @@ type DeleteManagerOptions struct { Confirm bool } +// CreateManagerOptions contains the settings scope and Gitea-managed display name. +type CreateManagerOptions struct { + ManagerSettingsOptions + Name string +} + // CreateManagerResult returns the Manager identity and one-time plaintext secret. type CreateManagerResult struct { ManagerID int64 + Name string Secret string } @@ -147,8 +158,12 @@ func GetManagerDetail(ctx context.Context, opts ManagerDetailOptions) (*ManagerD } // CreateManager creates a Manager identity and returns its secret once. -func CreateManager(ctx context.Context, opts ManagerSettingsOptions) (*CreateManagerResult, error) { - if err := validateManagerSettingsScope(ctx, opts); err != nil { +func CreateManager(ctx context.Context, opts CreateManagerOptions) (*CreateManagerResult, error) { + name, err := normalizeManagerDisplayName(opts.Name) + if err != nil { + return nil, err + } + if err := validateManagerSettingsScope(ctx, opts.ManagerSettingsOptions); err != nil { return nil, err } userID := opts.UserID @@ -156,12 +171,13 @@ func CreateManager(ctx context.Context, opts ManagerSettingsOptions) (*CreateMan userID = 0 } result := new(CreateManagerResult) - err := globallock.LockAndDo(ctx, codespaceUserRelationLockKey(userID), func(ctx context.Context) error { + err = globallock.LockAndDo(ctx, codespaceUserRelationLockKey(userID), func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error { - if err := validateManagerSettingsScope(ctx, opts); err != nil { + if err := validateManagerSettingsScope(ctx, opts.ManagerSettingsOptions); err != nil { return err } manager := &codespace_model.Manager{ + Name: name, UserID: userID, RuntimeState: codespace_model.ManagerRuntimeStateRecovering, TagsJSON: "[]", @@ -172,6 +188,7 @@ func CreateManager(ctx context.Context, opts ManagerSettingsOptions) (*CreateMan return err } result.ManagerID = manager.ID + result.Name = manager.Name return nil }) }) @@ -181,6 +198,19 @@ func CreateManager(ctx context.Context, opts ManagerSettingsOptions) (*CreateMan return result, nil } +func normalizeManagerDisplayName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" || len(name) > 255 { + return "", ErrManagerSettingsNameInvalid + } + for _, r := range name { + if unicode.IsControl(r) { + return "", ErrManagerSettingsNameInvalid + } + } + return name, nil +} + // DeleteManager removes one Manager identity and all Gitea records bound to it. func DeleteManager(ctx context.Context, opts DeleteManagerOptions) error { if !opts.Confirm { diff --git a/services/codespace/settings_test.go b/services/codespace/settings_test.go index 0368958affb5e..bb4d9ce9c820c 100644 --- a/services/codespace/settings_test.go +++ b/services/codespace/settings_test.go @@ -17,17 +17,34 @@ import ( func TestCreateManagerReturnsOneTimeSecret(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) - opts := ManagerSettingsOptions{Scope: ManagerSettingsScopeUser, UserID: 1} + opts := CreateManagerOptions{ + ManagerSettingsOptions: ManagerSettingsOptions{Scope: ManagerSettingsScopeUser, UserID: 1}, + Name: " Personal Manager ", + } result, err := CreateManager(t.Context(), opts) require.NoError(t, err) require.NotZero(t, result.ManagerID) + assert.Equal(t, "Personal Manager", result.Name) require.NotEmpty(t, result.Secret) manager, err := AuthenticateManager(t.Context(), result.ManagerID, result.Secret) require.NoError(t, err) + assert.Equal(t, "Personal Manager", manager.Name) assert.EqualValues(t, 1, manager.UserID) assert.Equal(t, codespace_model.ManagerRuntimeStateRecovering, manager.RuntimeState) } +func TestCreateManagerRejectsInvalidName(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + for _, name := range []string{"", " \t ", "bad\nname"} { + _, err := CreateManager(t.Context(), CreateManagerOptions{ + ManagerSettingsOptions: ManagerSettingsOptions{Scope: ManagerSettingsScopeSite}, + Name: name, + }) + require.ErrorIs(t, err, ErrManagerSettingsNameInvalid) + } +} + func TestListManagerSettingsScopesAndDeleteManager(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) @@ -158,9 +175,12 @@ func TestPersonalManagerDeleteRejectsForeignBindingBeforeCleanup(t *testing.T) { func TestManagerSettingsRequireIndividualUser(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) - _, err := CreateManager(t.Context(), ManagerSettingsOptions{ - Scope: ManagerSettingsScopeUser, - UserID: 3, + _, err := CreateManager(t.Context(), CreateManagerOptions{ + ManagerSettingsOptions: ManagerSettingsOptions{ + Scope: ManagerSettingsScopeUser, + UserID: 3, + }, + Name: "Manager", }) require.ErrorContains(t, err, "not an individual") } diff --git a/templates/codespace/settings_content.tmpl b/templates/codespace/settings_content.tmpl index b8f71baf05f6f..938d077092a7b 100644 --- a/templates/codespace/settings_content.tmpl +++ b/templates/codespace/settings_content.tmpl @@ -2,23 +2,9 @@

{{ctx.Locale.Tr "codespace.managers"}} ({{ctx.Locale.Tr "admin.total" .ManagerTotal}})
-
- {{$.CsrfTokenHtml}} - - +

- {{if .NewManagerSecret}} -
-
{{ctx.Locale.Tr "codespace.manager_secret_created" .NewManagerID}}
-
- - -
-
- {{end}} {{if .ManagerSettings.Managers}}
@@ -67,4 +53,38 @@

{{ctx.Locale.Tr "codespace.unassigned_codespaces"}} ({{.CodespaceTotal}})

{{template "codespace/manager_codespaces" .}} {{end}} + + + {{$.CsrfTokenHtml}} +
{{ctx.Locale.Tr "codespace.new_manager"}}
+
+

{{ctx.Locale.Tr "codespace.manager_create_description"}}

+
+ + +
+
+
+ + +
+ + + {{if .NewManagerSecret}} + + {{end}} diff --git a/web_src/js/features/common-page.ts b/web_src/js/features/common-page.ts index 3cb902efea71c..779a9daee0b83 100644 --- a/web_src/js/features/common-page.ts +++ b/web_src/js/features/common-page.ts @@ -6,7 +6,7 @@ import {addDelegatedEventListener, queryElems} from '../utils/dom.ts'; import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts'; import {initAvatarUploaderWithCropper} from './comp/Cropper.ts'; import {initCompSearchRepoBox} from './comp/SearchRepoBox.ts'; -import {initCodespaceSecretRepositoryPicker} from './comp/CodespaceSettings.ts'; +import {initCodespaceManagerSecretModal, initCodespaceSecretRepositoryPicker} from './comp/CodespaceSettings.ts'; import {initScopedWorkflowRequired} from './comp/ScopedWorkflows.ts'; const {appUrl, appSubUrl} = window.config; @@ -106,6 +106,7 @@ export function initGlobalComponent() { registerGlobalInitFunc('initTabSwitcher', initTabSwitcher); registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper); registerGlobalInitFunc('initSearchRepoBox', initCompSearchRepoBox); + registerGlobalInitFunc('initCodespaceManagerSecretModal', initCodespaceManagerSecretModal); registerGlobalInitFunc('initCodespaceSecretRepositoryPicker', initCodespaceSecretRepositoryPicker); registerGlobalInitFunc('initScopedWorkflowRequired', initScopedWorkflowRequired); } diff --git a/web_src/js/features/comp/CodespaceSettings.ts b/web_src/js/features/comp/CodespaceSettings.ts index b76e4ed750092..4228edc9d0693 100644 --- a/web_src/js/features/comp/CodespaceSettings.ts +++ b/web_src/js/features/comp/CodespaceSettings.ts @@ -1,4 +1,5 @@ import {attachSearchBox} from '../../modules/search.ts'; +import {showFomanticModal} from '../../modules/fomantic/modal.ts'; import {hideElem, showElem} from '../../utils/dom.ts'; import {svg} from '../../svg.ts'; @@ -87,3 +88,7 @@ export function initCodespaceSecretRepositoryPicker(root: HTMLElement) { } reset([], false); } + +export function initCodespaceManagerSecretModal(modal: HTMLElement) { + showFomanticModal(modal); +} From 24603c19b2e92e07a28ddd5b41fcae2536a40c9a Mon Sep 17 00:00:00 2001 From: dragon Date: Thu, 20 Aug 2026 11:45:19 +0800 Subject: [PATCH 15/16] refactor(codespace): align with upstream frontend conventions Assisted-by: Codex:GPT-5 --- go.mod | 4 +- go.sum | 4 +- routers/web/codespace/create.go | 11 + routers/web/codespace/open_test.go | 27 +-- routers/web/codespace/view_test.go | 16 +- templates/codespace/create_confirm.tmpl | 129 ++++++----- templates/codespace/list.tmpl | 22 +- templates/codespace/list_rows.tmpl | 137 +++++++----- templates/codespace/manager_codespaces.tmpl | 55 ++++- templates/codespace/state.tmpl | 231 ++++++++++++++++---- templates/codespace/user_permissions.tmpl | 93 +++++--- web_src/js/features/codespace.test.ts | 63 +++--- web_src/js/features/codespace.ts | 4 +- web_src/js/render/log.test.ts | 2 +- web_src/js/render/log.ts | 63 +++--- 15 files changed, 570 insertions(+), 291 deletions(-) diff --git a/go.mod b/go.mod index ce3eb94466007..f2d7cf576d252 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module gitea.dev -go 1.26.4 +go 1.26.0 toolchain go1.26.7 @@ -13,7 +13,7 @@ require ( gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 gitea.dev/actionslib v0.7.0 - gitea.dev/codespace-proto-go v0.0.0-20260806181530-58e42686428c + gitea.dev/codespace-proto-go v0.0.0-20260820032206-1d91f683f0cc gitea.dev/sdk v1.2.0 github.com/42wim/httpsig v1.2.4 github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 diff --git a/go.sum b/go.sum index 6ed8e4f4ac699..a7ed233194b74 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGq gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= gitea.dev/actionslib v0.7.0 h1:JCV8eeIGwjlXcuSr7ojEdQC22VoE2466+K+D9vuQWKQ= gitea.dev/actionslib v0.7.0/go.mod h1:DI3Lqp+8TrycM7/semMdqsDQOHaBskjIAuMn+SXfZJ0= -gitea.dev/codespace-proto-go v0.0.0-20260806181530-58e42686428c h1:Ap/cecRYu7GE9yGlINiapCbhmqSkoF3dQ0KPbZlWfwA= -gitea.dev/codespace-proto-go v0.0.0-20260806181530-58e42686428c/go.mod h1:PtyeQBWy45uIqw8bS6jPK2LYSizpS+W3vtvZ+7E9s10= +gitea.dev/codespace-proto-go v0.0.0-20260820032206-1d91f683f0cc h1:kc7ONpWtnjqVx0A1QLGt3WaptUcXhGP8TdLkQ/bXtuk= +gitea.dev/codespace-proto-go v0.0.0-20260820032206-1d91f683f0cc/go.mod h1:HYAE81idAawJyuWFLref9Das+loyey9+q7gY/J1DZWw= gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo= gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= diff --git a/routers/web/codespace/create.go b/routers/web/codespace/create.go index 9bc705d8fbe91..c9b99cd08efed 100644 --- a/routers/web/codespace/create.go +++ b/routers/web/codespace/create.go @@ -103,6 +103,13 @@ func Create(ctx *context.Context) { func renderCreateConfirm(ctx *context.Context, status int, plan *codespace_service.CreateCodespacePlan, opts codespace_service.CreateCodespaceOptions, errorMessage template.HTML) { ctx.RespHeader().Set("Cache-Control", "no-store") + selectedEnvironment := "" + for _, environment := range plan.Environments { + if environment.Selected { + selectedEnvironment = environment.Tag + break + } + } permissionRepositories := make([]createPermissionRepository, 0) permissionGrants := make(map[string]string, len(plan.Permissions)) for _, permission := range plan.Permissions { @@ -117,15 +124,19 @@ func renderCreateConfirm(ctx *context.Context, status int, plan *codespace_servi } } secretEnabled := make(map[string]bool, len(plan.RecommendedSecrets)) + hasPendingRecommendedSecret := false for _, secret := range plan.RecommendedSecrets { secretEnabled[secret.Name] = opts.RecommendedSecretEnabled[secret.Name] + hasPendingRecommendedSecret = hasPendingRecommendedSecret || !secret.Available } ctx.Data["Title"] = ctx.Tr("codespace.confirm_create") ctx.Data["CreatePlan"] = plan + ctx.Data["CreateSelectedEnvironment"] = selectedEnvironment ctx.Data["CreatePermissionRepositories"] = permissionRepositories ctx.Data["CreatePermissionGrants"] = permissionGrants ctx.Data["CreateRecommendedSecretEnabled"] = secretEnabled + ctx.Data["CreateHasPendingRecommendedSecret"] = hasPendingRecommendedSecret ctx.Data["CreateError"] = errorMessage ctx.HTML(status, tplCodespaceCreateConfirm) } diff --git a/routers/web/codespace/open_test.go b/routers/web/codespace/open_test.go index f3c125c835a24..926b80240d138 100644 --- a/routers/web/codespace/open_test.go +++ b/routers/web/codespace/open_test.go @@ -6,6 +6,7 @@ package codespace import ( "net/http" "net/url" + "slices" "strconv" "testing" "time" @@ -28,8 +29,11 @@ func TestOpenEndpointRedirectsWithOneTimeCode(t *testing.T) { codespaceUUID := "96969696-9696-4969-8969-969696969696" insertWebOpenCodespace(t, manager.ID, codespaceUUID, 91) require.NoError(t, codespace_service.ReportRuntimeMetadata(t.Context(), manager, codespace_service.ReportRuntimeMetadataOptions{ - CodespaceUUID: codespaceUUID, - Metadata: webOpenRuntimeMetadata(t, 91, []map[string]any{{"endpoint_id": "app-3000", "label": "App", "public": false}}), + CodespaceUUID: codespaceUUID, + Metadata: webOpenRuntimeMetadata(t, 91, []*codespacev1.RuntimeEndpoint{{ + EndpointId: "app-3000", + Label: "App", + }}), MetadataGeneration: 1, })) @@ -59,8 +63,12 @@ func TestOpenEndpointPublicRedirectsWithoutCode(t *testing.T) { codespaceUUID := "98989898-9898-4989-8989-989898989898" insertWebOpenCodespace(t, manager.ID, codespaceUUID, 92) require.NoError(t, codespace_service.ReportRuntimeMetadata(t.Context(), manager, codespace_service.ReportRuntimeMetadataOptions{ - CodespaceUUID: codespaceUUID, - Metadata: webOpenRuntimeMetadata(t, 92, []map[string]any{{"endpoint_id": "app-3000", "label": "App", "public": true}}), + CodespaceUUID: codespaceUUID, + Metadata: webOpenRuntimeMetadata(t, 92, []*codespacev1.RuntimeEndpoint{{ + EndpointId: "app-3000", + Label: "App", + Public: true, + }}), MetadataGeneration: 1, })) @@ -136,16 +144,9 @@ func insertWebOpenCodespace(t *testing.T, managerID int64, codespaceUUID string, })) } -func webOpenRuntimeMetadata(t *testing.T, operationRVersion int64, endpoints []map[string]any) *codespacev1.RuntimeMetadata { +func webOpenRuntimeMetadata(t *testing.T, operationRVersion int64, endpoints []*codespacev1.RuntimeEndpoint) *codespacev1.RuntimeMetadata { t.Helper() - metadataEndpoints := make([]*codespacev1.RuntimeEndpoint, 0, len(endpoints)+1) - for _, endpoint := range endpoints { - metadataEndpoints = append(metadataEndpoints, &codespacev1.RuntimeEndpoint{ - EndpointId: endpoint["endpoint_id"].(string), - Label: endpoint["label"].(string), - Public: endpoint["public"].(bool), - }) - } + metadataEndpoints := slices.Clone(endpoints) metadataEndpoints = append(metadataEndpoints, &codespacev1.RuntimeEndpoint{EndpointId: "workspace", Label: "Workspace"}) return &codespacev1.RuntimeMetadata{ Endpoints: metadataEndpoints, diff --git a/routers/web/codespace/view_test.go b/routers/web/codespace/view_test.go index 91bbb2bc90f9c..ecd39b54110a0 100644 --- a/routers/web/codespace/view_test.go +++ b/routers/web/codespace/view_test.go @@ -8,6 +8,7 @@ import ( "strconv" "testing" + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" codespace_model "gitea.dev/models/codespace" "gitea.dev/models/db" "gitea.dev/models/unittest" @@ -88,7 +89,9 @@ func TestDetailRendersCreatorCodespaceNoStore(t *testing.T) { require.True(t, ok) assert.Equal(t, codespaceUUID, view.UUID) assert.Equal(t, codespace_service.DetailModeLogs, ctx.Data["CodespaceTab"]) - assert.False(t, ctx.Data["CodespaceTabExplicit"].(bool)) + explicit, ok := ctx.Data["CodespaceTabExplicit"].(bool) + require.True(t, ok) + assert.False(t, explicit) } func TestDetailPreservesExplicitOverviewTab(t *testing.T) { @@ -110,7 +113,9 @@ func TestDetailPreservesExplicitOverviewTab(t *testing.T) { require.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, codespace_service.DetailModeOverview, ctx.Data["CodespaceTab"]) - assert.True(t, ctx.Data["CodespaceTabExplicit"].(bool)) + explicit, ok := ctx.Data["CodespaceTabExplicit"].(bool) + require.True(t, ok) + assert.True(t, explicit) assert.NotContains(t, resp.Body.String(), "data-log-next-offset=\"0\"") } @@ -122,8 +127,11 @@ func TestDetailOpensGatewayRecoveryModal(t *testing.T) { insertWebOpenCodespace(t, manager.ID, codespaceUUID, 94) codespaceID := webCodespaceIDByUUID(t, codespaceUUID) require.NoError(t, codespace_service.ReportRuntimeMetadata(t.Context(), manager, codespace_service.ReportRuntimeMetadataOptions{ - CodespaceUUID: codespaceUUID, - Metadata: webOpenRuntimeMetadata(t, 94, []map[string]any{{"endpoint_id": "app-3000", "label": "App", "public": false}}), + CodespaceUUID: codespaceUUID, + Metadata: webOpenRuntimeMetadata(t, 94, []*codespacev1.RuntimeEndpoint{{ + EndpointId: "app-3000", + Label: "App", + }}), MetadataGeneration: 1, })) diff --git a/templates/codespace/create_confirm.tmpl b/templates/codespace/create_confirm.tmpl index 5ebb8198bc83b..c4cad5365d9bf 100644 --- a/templates/codespace/create_confirm.tmpl +++ b/templates/codespace/create_confirm.tmpl @@ -34,65 +34,73 @@ {{end}}
-

{{ctx.Locale.Tr "codespace.development_environment"}}

-
-
- - {{$selectedEnvironment := ""}} - {{range .CreatePlan.Environments}}{{if .Selected}}{{$selectedEnvironment = .Tag}}{{end}}{{end}} -
- {{range .Codespaces}} + {{range $codespace := .Codespaces}} - {{if $.IsSiteManagerSettings}}{{end}} - - + {{if $.IsSiteManagerSettings}}{{end}} + + diff --git a/templates/codespace/state.tmpl b/templates/codespace/state.tmpl index c1502517dc288..3a2bb5ea65270 100644 --- a/templates/codespace/state.tmpl +++ b/templates/codespace/state.tmpl @@ -1,21 +1,32 @@ {{$cs := .Codespace}} -
+
-

{{if $cs.RepoLink}}{{$cs.RepoFullName}}{{else}}{{ctx.Locale.Tr "codespace.codespace"}}{{end}}

+

+ {{if $cs.RepoLink}}{{$cs.RepoFullName}}{{else}}{{ctx.Locale.Tr "codespace.codespace"}}{{end}} +

{{ctx.Locale.Tr $cs.DisplayStatusKey}}
{{svg "octicon-git-branch" 14}} {{$cs.RefType}}: {{$cs.RefName}} - {{if $cs.CommitSHA}}{{if $cs.CommitLink}}{{ShortSha $cs.CommitSHA}}{{else}}{{ShortSha $cs.CommitSHA}}{{end}}{{end}} + {{if $cs.CommitSHA}} + {{if $cs.CommitLink}}{{ShortSha $cs.CommitSHA}}{{else}}{{ShortSha $cs.CommitSHA}}{{end}} + {{end}} {{ctx.Locale.Tr "codespace.created"}} {{DateUtils.TimeSince $cs.CreatedUnix}}
{{if or (ne $cs.DisplayStatus "running") $cs.BootStageKey}}
{{ctx.Locale.Tr $cs.StatusSummaryKey}} - {{if $cs.BootStageKey}}{{ctx.Locale.Tr "codespace.current_stage"}}: {{ctx.Locale.Tr $cs.BootStageKey}}{{end}} + {{if $cs.BootStageKey}} + {{ctx.Locale.Tr "codespace.current_stage"}}: {{ctx.Locale.Tr $cs.BootStageKey}} + {{end}}
{{end}}
@@ -23,21 +34,63 @@ {{$hasOpen := and $cs.Workspace $cs.CanOpen}} {{$continueIsPrimary := and (not $hasOpen) (not $cs.CanResume) $cs.CanContinue}} {{if $hasOpen}} - + {{else if $cs.CanResume}} -
{{$.CsrfTokenHtml}} +
+ {{$.CsrfTokenHtml}} + + {{else if $continueIsPrimary}} -
{{$.CsrfTokenHtml}} +
+ {{$.CsrfTokenHtml}} + + + {{end}} + {{if and $cs.CanContinue (not $continueIsPrimary)}} + + {{end}} + {{if $cs.CanStop}} + {{end}} - {{if and $cs.CanContinue (not $continueIsPrimary)}}{{end}} - {{if $cs.CanStop}}{{end}} {{if or $cs.CanConfigureAutoStop $cs.CanDelete}} {{end}} @@ -53,22 +106,75 @@ {{if eq .CodespaceTab "overview"}} {{$hasConnections := or $cs.SSH $cs.Endpoints}}
- {{if $hasConnections}} -
- {{if $cs.SSH}} -
-

{{svg "octicon-key" 18}}{{ctx.Locale.Tr "codespace.ssh"}}

-
-
{{ctx.Locale.Tr "codespace.ssh_verify_host_key"}}
{{ctx.Locale.Tr "codespace.ssh_host_key_algorithm"}}
{{$cs.SSH.HostKeyAlgorithm}}
{{ctx.Locale.Tr "codespace.ssh_host_key_fingerprint"}}
{{$cs.SSH.HostKeyFingerprint}}
{{if $cs.SSH.HostKeyUpdatedUnix}}
{{ctx.Locale.Tr "codespace.ssh_host_key_updated"}}
{{DateUtils.AbsoluteShort $cs.SSH.HostKeyUpdatedUnix}}
{{end}}
-
- {{end}} + {{if $hasConnections}} +
+ {{if $cs.SSH}} +
+

{{svg "octicon-key" 18}}{{ctx.Locale.Tr "codespace.ssh"}}

+
+ + +
+
+ {{ctx.Locale.Tr "codespace.ssh_verify_host_key"}} +
+
{{ctx.Locale.Tr "codespace.ssh_host_key_algorithm"}}
+
{{$cs.SSH.HostKeyAlgorithm}}
+
{{ctx.Locale.Tr "codespace.ssh_host_key_fingerprint"}}
+
+ {{$cs.SSH.HostKeyFingerprint}} + +
+ {{if $cs.SSH.HostKeyUpdatedUnix}} +
{{ctx.Locale.Tr "codespace.ssh_host_key_updated"}}
+
{{DateUtils.AbsoluteShort $cs.SSH.HostKeyUpdatedUnix}}
+ {{end}} +
+
+
+ {{end}} {{if $cs.Endpoints}} -
-

{{ctx.Locale.Tr "codespace.ports"}}

-
{{if $.IsSiteManagerSettings}} - {{.ShortUUID}} + {{$codespace.ShortUUID}} {{else}} - {{.ShortUUID}} + {{$codespace.ShortUUID}} {{end}} - {{if .RepoFullName}}{{.RepoFullName}}{{else}}{{ctx.Locale.Tr "codespace.repository_unavailable"}}{{end}} - {{if .RefName}}
{{ctx.Locale.Tr "codespace.ref"}}: {{.RefName}}
{{end}} + {{if $codespace.RepoFullName}}{{$codespace.RepoFullName}}{{else}}{{ctx.Locale.Tr "codespace.repository_unavailable"}}{{end}} + {{if $codespace.RefName}}
{{ctx.Locale.Tr "codespace.ref"}}: {{$codespace.RefName}}
{{end}}
{{if .UserDisplayName}}{{.UserDisplayName}}{{else}}{{ctx.Locale.Tr "codespace.user_id" .UserID}}{{end}}{{ctx.Locale.Tr (print "codespace.status." .DisplayStatus)}}{{DateUtils.AbsoluteShort .UpdatedUnix}}{{if $codespace.UserDisplayName}}{{$codespace.UserDisplayName}}{{else}}{{ctx.Locale.Tr "codespace.user_id" $codespace.UserID}}{{end}}{{ctx.Locale.Tr (print "codespace.status." $codespace.DisplayStatus)}}{{DateUtils.AbsoluteShort $codespace.UpdatedUnix}} {{if $.IsSiteManagerSettings}}
- {{if .CanStop}}
{{$.CsrfTokenHtml}}
{{end}} - {{if .CanDelete}}
{{$.CsrfTokenHtml}}
{{end}} - {{if .CanForceDelete}}
{{$.CsrfTokenHtml}}
{{end}} + {{if $codespace.CanStop}} +
+ {{$.CsrfTokenHtml}} + +
+ {{end}} + {{if $codespace.CanDelete}} +
+ {{$.CsrfTokenHtml}} + +
+ {{end}} + {{if $codespace.CanForceDelete}} +
+ {{$.CsrfTokenHtml}} + + +
+ {{end}}
{{else}} - {{svg "octicon-arrow-right"}} + {{svg "octicon-arrow-right"}} {{end}}
- - {{range $cs.Endpoints}}{{end}} -
{{ctx.Locale.Tr "codespace.service"}}{{ctx.Locale.Tr "codespace.port"}}{{ctx.Locale.Tr "codespace.visibility"}}
{{.Label}}
{{if .Port}}:{{.Port}}{{else}}-{{end}}{{if .Public}}{{ctx.Locale.Tr "codespace.public_endpoint"}}{{else}}{{ctx.Locale.Tr "codespace.authenticated_endpoint"}}{{end}}
{{if .Port}}:{{.Port}}{{else}}-{{end}}{{if .Public}}{{ctx.Locale.Tr "codespace.public_endpoint"}}{{else}}{{ctx.Locale.Tr "codespace.authenticated_endpoint"}}{{end}}{{if .CanOpen}}{{else}}{{end}}
+
+

{{ctx.Locale.Tr "codespace.ports"}}

+ + + + + + + + + + + {{range $endpoint := $cs.Endpoints}} + + + + + + + {{end}} + +
{{ctx.Locale.Tr "codespace.service"}}{{ctx.Locale.Tr "codespace.port"}}{{ctx.Locale.Tr "codespace.visibility"}}
+ {{$endpoint.Label}} +
+ {{if $endpoint.Port}}:{{$endpoint.Port}}{{else}}-{{end}} + {{if $endpoint.Public}}{{ctx.Locale.Tr "codespace.public_endpoint"}}{{else}}{{ctx.Locale.Tr "codespace.authenticated_endpoint"}}{{end}} +
+
{{if $endpoint.Port}}:{{$endpoint.Port}}{{else}}-{{end}}{{if $endpoint.Public}}{{ctx.Locale.Tr "codespace.public_endpoint"}}{{else}}{{ctx.Locale.Tr "codespace.authenticated_endpoint"}}{{end}} + {{if $endpoint.CanOpen}} + + {{else}} + + {{end}} +
{{end}} @@ -80,25 +186,60 @@

{{ctx.Locale.Tr "codespace.access_unavailable"}}

- {{end}} -
{{end}} diff --git a/templates/codespace/user_permissions.tmpl b/templates/codespace/user_permissions.tmpl index 4a9ddf7c68239..5ca70a7912601 100644 --- a/templates/codespace/user_permissions.tmpl +++ b/templates/codespace/user_permissions.tmpl @@ -1,34 +1,71 @@ {{template "user/settings/layout_head" (dict "pageClass" "user settings codespace permissions")}}

{{ctx.Locale.Tr "codespace.permissions"}}

-
-

{{ctx.Locale.Tr "codespace.permissions_description"}}

-
- {{if .PermissionAuthorizations}} - {{range .PermissionAuthorizations}} - {{$authorization := .}} -
-
-
- {{.SourceRepository}} - {{if .Revoked}}{{ctx.Locale.Tr "codespace.permission_revoked"}}{{end}} -
{{ctx.Locale.Tr "codespace.permission_codespace_count" .CodespaceCount}} ยท {{DateUtils.AbsoluteShort .UpdatedUnix}}
+
+

{{ctx.Locale.Tr "codespace.permissions_description"}}

+
+ {{if .PermissionAuthorizations}} + {{range $authorization := .PermissionAuthorizations}} +
+
+
+ {{$authorization.SourceRepository}} + {{if $authorization.Revoked}}{{ctx.Locale.Tr "codespace.permission_revoked"}}{{end}} +
{{ctx.Locale.Tr "codespace.permission_codespace_count" $authorization.CodespaceCount}} ยท {{DateUtils.AbsoluteShort $authorization.UpdatedUnix}}
+
+ {{if not $authorization.Revoked}} +
+ {{$.CsrfTokenHtml}} + + + +
+ {{end}}
- {{if not .Revoked}} -
- {{$.CsrfTokenHtml}} - - - -
- {{end}} -
- - - {{range .Repositories}} - - - {{end}} -
{{ctx.Locale.Tr "repo.repository"}}{{ctx.Locale.Tr "units.unit"}}{{ctx.Locale.Tr "codespace.requested"}}{{ctx.Locale.Tr "codespace.granted"}}
{{.Repository}}{{.UnitName}}{{.RequestedMode}}{{.GrantedMode}}{{if .CanReduceToRead}}
{{$.CsrfTokenHtml}}
{{end}} {{if .CanRevoke}}
{{$.CsrfTokenHtml}}
{{end}}
+ + + + + + + + + + + + {{range $repository := $authorization.Repositories}} + + + + + + + + {{end}} + +
{{ctx.Locale.Tr "repo.repository"}}{{ctx.Locale.Tr "units.unit"}}{{ctx.Locale.Tr "codespace.requested"}}{{ctx.Locale.Tr "codespace.granted"}}
{{$repository.Repository}}{{$repository.UnitName}}{{$repository.RequestedMode}}{{$repository.GrantedMode}} + {{if $repository.CanReduceToRead}} +
+ {{$.CsrfTokenHtml}} + + + + + + +
+ {{end}} + {{if $repository.CanRevoke}} +
+ {{$.CsrfTokenHtml}} + + + + + + +
+ {{end}} +
{{end}} {{else}} diff --git a/web_src/js/features/codespace.test.ts b/web_src/js/features/codespace.test.ts index b647a344d63b8..61b21d77e57a0 100644 --- a/web_src/js/features/codespace.test.ts +++ b/web_src/js/features/codespace.test.ts @@ -1,5 +1,6 @@ import {initCodespaceCreateForm, initCodespaceLiveState} from './codespace.ts'; import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts'; +import {captureNavigations} from '../utils/testhelper.ts'; vi.mock('../modules/fomantic/modal.ts', () => ({hideFomanticModal: vi.fn(), showFomanticModal: vi.fn()})); vi.mock('../modules/fomantic/base.ts', () => ({ @@ -46,35 +47,31 @@ test('codespace create environment selection updates its explanation', () => { }); test('codespace create configuration preview preserves its source and environment', () => { - const assignSpy = vi.spyOn(window.location, 'assign').mockImplementation(() => undefined); - try { - document.body.innerHTML = ` -
- - -
`; - - initCodespaceCreateForm(); - const select = document.querySelector('[data-codespace-dev-container]')!; - select.value = '.devcontainer/node/devcontainer.json'; - select.dispatchEvent(new Event('change')); - - const previewURL = new URL(String(assignSpy.mock.calls[0][0])); - expect(previewURL.pathname).toBe('/owner/repo/codespaces/new'); - expect(Object.fromEntries(previewURL.searchParams)).toEqual({ - ref_type: 'branch', - ref_name: 'main', - dev_container: '.devcontainer/node/devcontainer.json', - environment_tag: 'standard', - }); - } finally { - assignSpy.mockRestore(); - document.body.replaceChildren(); - } + const navigations = captureNavigations(); + document.body.innerHTML = ` +
+ + +
`; + + initCodespaceCreateForm(); + const select = document.querySelector('[data-codespace-dev-container]')!; + select.value = '.devcontainer/node/devcontainer.json'; + select.dispatchEvent(new Event('change')); + + const previewURL = new URL(navigations.at(-1)!.url); + expect(previewURL.pathname).toBe('/owner/repo/codespaces/new'); + expect(Object.fromEntries(previewURL.searchParams)).toEqual({ + ref_type: 'branch', + ref_name: 'main', + dev_container: '.devcontainer/node/devcontainer.json', + environment_tag: 'standard', + }); + document.body.replaceChildren(); }); test('initCodespaceLiveState opens the gateway recovery modal', () => { @@ -116,9 +113,9 @@ test('initCodespaceLiveState refreshes the state fragment', {concurrent: false}, initCodespaceLiveState(); await vi.advanceTimersByTimeAsync(10); + await vi.waitFor(() => expect(document.querySelector('#codespace-live-state')!.textContent).toContain('new')); expect(fetchMock).toHaveBeenCalledWith('/-/codespaces/uuid/state', expect.objectContaining({method: 'GET'})); - expect(document.querySelector('#codespace-live-state')!.textContent).toContain('new'); } finally { vi.useRealTimers(); vi.unstubAllGlobals(); @@ -200,11 +197,12 @@ test('initCodespaceLiveState immediately appends structured log lines', {concurr Object.defineProperties(logView, { clientHeight: {value: 100}, scrollHeight: {value: 200}, + scrollTop: {value: 100, writable: true}, }); - logView.scrollTop = 100; initCodespaceLiveState(); await vi.advanceTimersByTimeAsync(0); + await vi.waitFor(() => expect(logView.querySelectorAll('.codespace-log-line-message')).toHaveLength(2)); expect(fetchMock).toHaveBeenCalledWith('/-/codespaces/uuid/logs?offset=0', expect.objectContaining({method: 'GET'})); expect(Array.from(logView.querySelectorAll('.codespace-log-line-number'), (el) => el.textContent)).toEqual(['1', '2']); @@ -238,11 +236,12 @@ test('codespace log refresh preserves a reader position away from the bottom', { Object.defineProperties(logView, { clientHeight: {value: 100}, scrollHeight: {value: 300}, + scrollTop: {value: 80, writable: true}, }); - logView.scrollTop = 80; initCodespaceLiveState(); await vi.advanceTimersByTimeAsync(0); + await vi.waitFor(() => expect(logView.querySelector('.codespace-log-line-message')).not.toBeNull()); expect(logView.querySelector('.codespace-log-line-message')!.textContent).toBe('third'); expect(logView.scrollTop).toBe(80); diff --git a/web_src/js/features/codespace.ts b/web_src/js/features/codespace.ts index 523241ede07c9..c983c366959ca 100644 --- a/web_src/js/features/codespace.ts +++ b/web_src/js/features/codespace.ts @@ -4,7 +4,7 @@ import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts import {toggleFullScreen} from '../utils.ts'; import {formatDatetime} from '../utils/time.ts'; import {AnsiLineRenderer} from '../render/ansi.ts'; -import {createLogLineMessage, decodeLogLineMessage, parseLogLineCommand, type LogLine} from '../render/log.ts'; +import {createLogLineMessage, decodeLineMessage, parseLogLineCommand, type LogLine} from '../render/log.ts'; const liveStateSelector = '#codespace-live-state'; const logViewSelector = '#codespace-log-view'; @@ -339,7 +339,7 @@ async function appendCodespaceLogLines(logEl: HTMLElement, lines: CodespaceLogLi details.open = true; const summary = document.createElement('summary'); summary.className = 'codespace-log-group-summary'; - summary.textContent = decodeLogLineMessage(parsedLine, command).trim(); + summary.textContent = decodeLineMessage(parsedLine, command).trim(); const body = document.createElement('div'); body.className = 'codespace-log-group-body'; details.append(summary, body); diff --git a/web_src/js/render/log.test.ts b/web_src/js/render/log.test.ts index 57d17b11684cb..b331edb51c961 100644 --- a/web_src/js/render/log.test.ts +++ b/web_src/js/render/log.test.ts @@ -1,5 +1,5 @@ -import {AnsiLineRenderer} from './ansi.ts'; import {createLogLineMessage, parseLogLineCommand} from './log.ts'; +import {AnsiLineRenderer} from './ansi.ts'; test('LogLineMessage', () => { const cases = { diff --git a/web_src/js/render/log.ts b/web_src/js/render/log.ts index af21038a0d659..e74393e815fbc 100644 --- a/web_src/js/render/log.ts +++ b/web_src/js/render/log.ts @@ -1,5 +1,5 @@ -import type {AnsiLineRenderer} from './ansi.ts'; import {createElementFromAttrs} from '../utils/dom.ts'; +import type {AnsiLineRenderer} from './ansi.ts'; // How GitHub Actions logs work: // * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...." @@ -12,6 +12,7 @@ const LogLinePrefixCommandMap: Record = { '##[group]': 'group', '::endgroup::': 'endgroup', '##[endgroup]': 'endgroup', + '##[error]': 'error', '##[warning]': 'warning', '##[notice]': 'notice', @@ -37,52 +38,58 @@ export type LogLine = { export type LogLineCommandName = 'group' | 'endgroup' | 'command' | 'error' | 'warning' | 'notice' | 'debug' | 'hidden'; export type LogLineCommand = { - name: LogLineCommandName; - prefix: string; + name: LogLineCommandName, + prefix: string, }; export function parseLogLineCommand(line: LogLine): LogLineCommand | null { // TODO: in the future it can be refactored to be a general parser that can parse arguments, drop the "prefix match" for (const [prefix, commandName] of Object.entries(LogLinePrefixCommandMap)) { - if (line.message.startsWith(prefix)) return {name: commandName, prefix}; + if (line.message.startsWith(prefix)) { + return {name: commandName, prefix}; + } } // Handle ::cmd:: and ::cmd args:: format (runner may pass these through raw) const match = LogLineCmdPattern.exec(line.message); - return match ? {name: match[1] as LogLineCommandName, prefix: match[0]} : null; + if (match) { + return {name: match[1] as LogLineCommandName, prefix: match[0]}; + } + return null; } const LogLineLabelMap: Partial> = { - error: 'Error', - warning: 'Warning', - notice: 'Notice', - debug: 'Debug', + 'error': 'Error', + 'warning': 'Warning', + 'notice': 'Notice', + 'debug': 'Debug', }; -export function decodeLogLineMessage(line: LogLine, command: LogLineCommand | null): string { +export function decodeLineMessage(line: LogLine, cmd: LogLineCommand | null): string { // TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::), // it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands) - if (!command) return line.message; - let message = line.message.substring(command.prefix.length); - if (command.name === 'command') return message; // "command" is only an output tag, do not parse or escape it + if (!cmd) return line.message; + let msg = line.message.substring(cmd.prefix.length); + if (cmd.name === 'command') return msg; // "command" is only an output tag, do not parse or escape it // "##[cmd]" also escapes ";" and "]" which delimit its header, "::cmd::" does not - if (!command.prefix.startsWith('::')) message = message.replace(/%3B/g, ';').replace(/%5D/g, ']'); - // AnsiLineRenderer breaks a line per "\r", so "%0D%0A" is one break. "%25" last keeps "%250A" literal - return message.replace(/(?:%0D)?%0A/g, '\n').replace(/%0D/g, '\r').replace(/%25/g, '%'); + if (!cmd.prefix.startsWith('::')) msg = msg.replace(/%3B/g, ';').replace(/%5D/g, ']'); + // a line breaks per "\r" when rendered, so "%0D%0A" is one break. "%25" last keeps "%250A" literal + return msg.replace(/(?:%0D)?%0A/g, '\n').replace(/%0D/g, '\r').replace(/%25/g, '%'); } -export function createLogLineMessage(ansi: AnsiLineRenderer, line: LogLine, command: LogLineCommand | null) { - const attrs = {class: 'log-msg'}; - if (command?.name) attrs.class += ` log-cmd-${command.name}`; // make it easier to add styles to some commands like "error" - const content = decodeLogLineMessage(line, command); - const element = createElementFromAttrs('span', attrs); - const label = command ? LogLineLabelMap[command.name] : null; +export function createLogLineMessage(ansi: AnsiLineRenderer, line: LogLine, cmd: LogLineCommand | null) { + const logMsgAttrs = {class: 'log-msg'}; + if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd.name}`; // make it easier to add styles to some commands like "error" + + const msgContent = decodeLineMessage(line, cmd); + const logMsg = createElementFromAttrs('span', logMsgAttrs); + const label = cmd ? LogLineLabelMap[cmd.name] : null; if (label) { - element.append(createElementFromAttrs('span', {class: 'log-msg-label'}, `${label}:`)); - const message = document.createElement('span'); - ansi.renderLine(message, ` ${content.trimStart()}`); - element.append(message); + logMsg.append(createElementFromAttrs('span', {class: 'log-msg-label'}, `${label}:`)); + const msgSpan = document.createElement('span'); + ansi.renderLine(msgSpan, ` ${msgContent.trimStart()}`); + logMsg.append(msgSpan); } else { - ansi.renderLine(element, content); + ansi.renderLine(logMsg, msgContent); } - return element; + return logMsg; } From 900bddc54de47201ff7e53a99d68e87e0488585a Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 8 Sep 2026 09:34:04 +0800 Subject: [PATCH 16/16] enhance(codespace): streamline management and align settings UI Separate manager creation from credential provisioning, improve template editing and lifecycle feedback, and align Codespace controls with existing Gitea styles. Assisted-by: Codex:GPT-5 --- options/locale/locale_en-US.json | 36 ++++- routers/api/codespace/manager/manager_test.go | 21 +-- routers/web/codespace/action.go | 22 +-- routers/web/codespace/action_test.go | 13 ++ routers/web/codespace/create.go | 40 +++++- routers/web/codespace/settings.go | 84 +++++++++--- routers/web/codespace/view.go | 30 +++++ routers/web/codespace/view_test.go | 10 +- routers/web/web.go | 4 + services/codespace/create.go | 18 ++- services/codespace/create_test.go | 20 ++- services/codespace/devcontainer.go | 126 ++++++++---------- services/codespace/devcontainer_template.go | 14 +- services/codespace/open_token.go | 8 +- services/codespace/settings.go | 84 +++++++++--- services/codespace/settings_test.go | 39 +++++- services/codespace/token.go | 18 +-- services/codespace/view.go | 3 + services/codespace/view_test.go | 2 + templates/codespace/create_confirm.tmpl | 20 ++- templates/codespace/detail.tmpl | 3 +- .../devcontainer_templates_content.tmpl | 41 +++--- templates/codespace/list.tmpl | 4 +- templates/codespace/list_rows.tmpl | 10 +- templates/codespace/list_state.tmpl | 4 + templates/codespace/manager_codespaces.tmpl | 16 +-- .../codespace/manager_detail_content.tmpl | 71 ++++++++-- templates/codespace/manager_secret_modal.tmpl | 21 +++ templates/codespace/open_modal.tmpl | 6 +- templates/codespace/refresh_notice.tmpl | 5 + templates/codespace/settings_content.tmpl | 25 +--- templates/codespace/settings_modal.tmpl | 14 +- templates/codespace/state.tmpl | 40 +++--- templates/codespace/user_secrets.tmpl | 16 +-- templates/repo/codespace_panel.tmpl | 2 +- tests/integration/codespace_test.go | 68 +++++++++- web_src/css/codespace.css | 99 ++++++++++---- web_src/js/features/codespace.test.ts | 65 +++++++-- web_src/js/features/codespace.ts | 107 +++++++++++---- web_src/js/features/common-page.ts | 3 +- .../features/comp/CodespaceSettings.test.ts | 111 ++++++++++++++- web_src/js/features/comp/CodespaceSettings.ts | 105 ++++++++++++++- 42 files changed, 1083 insertions(+), 365 deletions(-) create mode 100644 templates/codespace/list_state.tmpl create mode 100644 templates/codespace/manager_secret_modal.tmpl create mode 100644 templates/codespace/refresh_notice.tmpl diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 5defadc227036..12cb2fd305cae 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3069,12 +3069,29 @@ "codespace.create_unavailable": "Codespace creation is unavailable", "codespace.creator": "Creator", "codespace.delete_confirm": "Delete Codespace %s? Its workspace, credentials, and operation logs will be removed.", + "codespace.delete_creator_confirm": "Delete the Codespace for %s (%s), created %s? Its workspace, credentials, and operation logs will be removed.", + "codespace.refresh_interrupted": "Updates are interrupted. The displayed state may be out of date.", + "codespace.refresh_unavailable": "This Codespace page is no longer available. Check your access or return to the Codespaces list.", + "codespace.stopped_manager_unavailable": "Stopped. Its manager is unavailable; resume will be available when the manager reconnects.", + "codespace.configuration_source.repository": "Repository configurations", + "codespace.configuration_source.personal": "Personal templates", + "codespace.configuration_source.site": "Site templates", + "codespace.configuration_invalid": "The selected Dev Container configuration is unavailable or invalid. Check its JSONC, permissions and secret declarations, or select another configuration.", + "codespace.configuration_review": "Update configuration", + "codespace.configuration_select": "Select a configuration", + "codespace.configuration_review_warning": "Updating the configuration requires reviewing its permissions again. Any secret values entered below will be cleared.", "codespace.delete_detail_confirm": "Delete this Codespace? Its workspace, credentials, and operation logs will be removed.", "codespace.delete_title": "Delete Codespace", "codespace.dev_container_configuration": "Dev Container configuration", "codespace.dev_container_template_content": "Dev Container content", - "codespace.dev_container_template_content_help": "Use an image-based Dev Container JSON or JSONC configuration. Repository-relative build context is only available from repository configurations.", - "codespace.dev_container_template_delete_confirm": "Delete Dev Container template %s?", + "codespace.dev_container_template_content_help": "Use an image-based Dev Container JSON or JSONC configuration. Store configurations needing Dockerfiles, Compose files, relative Features or relative bind mounts in the repository alongside those files.", + "codespace.dev_container_template_delete_confirm": "Delete Dev Container template %s? It will no longer be available for new Codespaces. Existing Codespaces keep their configuration.", + "codespace.dev_container_template_add": "Add template", + "codespace.dev_container_template_create": "Create template", + "codespace.dev_container_template_edit": "Edit Dev Container template", + "codespace.dev_container_template_name_invalid": "Enter a template name of at most 255 bytes.", + "codespace.dev_container_template_personal_help": "These templates are available to you when creating Codespaces. Changes apply to new Codespaces only.", + "codespace.dev_container_template_site_help": "These templates are available to all users when creating Codespaces. Changes apply to new Codespaces only.", "codespace.dev_container_templates": "Dev Container templates", "codespace.dev_container_templates_saved": "Saved Dev Container templates", "codespace.development_environment": "Development environment", @@ -3128,7 +3145,7 @@ "codespace.managers": "Codespace Managers", "codespace.memory": "Memory", "codespace.manager_create": "Create Manager", - "codespace.manager_create_description": "Create a Manager identity and copy its one-time secret before configuring the Manager process.", + "codespace.manager_create_description": "Create a Manager identity. Generate its connection credentials from the Manager settings when you are ready to connect it.", "codespace.manager_name": "Name", "codespace.manager_name_invalid": "Manager name is required and must not contain control characters.", "codespace.new_manager": "New", @@ -3167,7 +3184,17 @@ "codespace.recent": "Recent Codespaces", "codespace.recommended_secrets": "Recommended secrets", "codespace.recommended_secrets_description": "The selected Dev Container recommends these environment variables. Existing values remain hidden. You can allow this repository or provide an optional missing value before creation.", - "codespace.manager_secret_created": "Manager \"%s\" (ID %d) was created. Copy this secret now; it will not be shown again.", + "codespace.manager_credentials": "Connection credentials", + "codespace.manager_connection": "Connection and credentials", + "codespace.manager_waiting_declaration": "Waiting for the Manager to report its connection information.", + "codespace.manager_secret_missing": "Connection credentials have not been generated.", + "codespace.manager_secret_generated": "Connection credentials have been generated. The secret is only shown when generated or reset.", + "codespace.manager_secret_generate": "Generate credentials", + "codespace.manager_secret_reset": "Reset credentials", + "codespace.manager_secret_reset_notice": "Reset connection credentials? The previous secret will stop authenticating new requests. Update this Gitea connection in the Manager administration panel and restart the affected Manager/Gateway processes promptly; until then, control requests and lease renewals may fail.", + "codespace.manager_secret_copy_notice": "Copy this secret now; it will not be shown again. Save it in the Manager administration panel, then start or restart the affected Manager/Gateway processes.", + "codespace.manager_secret_response_error": "The credentials response could not be read. The secret may already have changed. Reset the credentials again to obtain a new secret.", + "codespace.manager_settings_changed": "The Manager was deleted or its credentials changed during this request. Refresh the page before trying again.", "codespace.repo_empty": "No codespaces for this repository.", "codespace.repository": "Repository", "codespace.repository_access": "Repository access", @@ -3197,7 +3224,6 @@ "codespace.secret_size_limit": "The Codespace secrets available to a repository cannot exceed 512 KiB.", "codespace.secret_update_failed": "The Codespace secret could not be updated. Check its name, value, repository access, and size limits.", "codespace.secret_updated": "Codespace secret settings have been updated.", - "codespace.secret_value_hidden": "Value is hidden", "codespace.secret_value_invalid": "Enter a non-empty UTF-8 value no larger than 48 KiB.", "codespace.secrets": "Codespace secrets", "codespace.secrets_available_description": "These secret names are currently authorized for this repository. Values remain hidden.", diff --git a/routers/api/codespace/manager/manager_test.go b/routers/api/codespace/manager/manager_test.go index 74dadf9bd1593..29f754812170e 100644 --- a/routers/api/codespace/manager/manager_test.go +++ b/routers/api/codespace/manager/manager_test.go @@ -31,19 +31,20 @@ func TestManagerServiceProtocolAuthenticationAndDeclaration(t *testing.T) { client, cleanup := newManagerTestClient(t) defer cleanup() - created, err := codespace_service.CreateManager(t.Context(), codespace_service.CreateManagerOptions{ + managerID, err := codespace_service.CreateManager(t.Context(), codespace_service.CreateManagerOptions{ ManagerSettingsOptions: codespace_service.ManagerSettingsOptions{Scope: codespace_service.ManagerSettingsScopeSite}, Name: "Gitea Manager", }) require.NoError(t, err) - require.Positive(t, created.ManagerID) - require.NotEmpty(t, created.Secret) - checked, err := client.CheckManager(t.Context(), managerRequest(created.ManagerID, created.Secret, &codespacev1.CheckManagerRequest{ProtocolVersion: 1})) + require.Positive(t, managerID) + secret, err := codespace_service.ResetManagerSecret(t.Context(), codespace_service.ManagerSettingsOptions{Scope: codespace_service.ManagerSettingsScopeSite}, managerID, false) + require.NoError(t, err) + checked, err := client.CheckManager(t.Context(), managerRequest(managerID, secret, &codespacev1.CheckManagerRequest{ProtocolVersion: 1})) require.NoError(t, err) assert.Equal(t, "Gitea Manager", checked.Msg.GetManagerName()) assert.NotEmpty(t, checked.Msg.GetGiteaWebUrl()) managerBeforeDeclare := new(codespace_model.Manager) - has, err := db.GetEngine(t.Context()).ID(created.ManagerID).Get(managerBeforeDeclare) + has, err := db.GetEngine(t.Context()).ID(managerID).Get(managerBeforeDeclare) require.NoError(t, err) require.True(t, has) assert.Zero(t, managerBeforeDeclare.LastOnlineUnix) @@ -60,24 +61,24 @@ func TestManagerServiceProtocolAuthenticationAndDeclaration(t *testing.T) { GatewaySshHostKeyFingerprintSha256: " SHA256:test ", GatewaySshHostKeyUpdatedUnix: 1, } - _, err = client.DeclareManager(t.Context(), managerRequest(created.ManagerID, "bad-secret", declaration)) + _, err = client.DeclareManager(t.Context(), managerRequest(managerID, "bad-secret", declaration)) require.Error(t, err) assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) assert.Equal(t, "unauthenticated", failureCategory(t, err)) - _, err = client.DeclareManager(t.Context(), managerRequest(created.ManagerID+1000, created.Secret, declaration)) + _, err = client.DeclareManager(t.Context(), managerRequest(managerID+1000, secret, declaration)) require.Error(t, err) assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) assert.Equal(t, "manager_unregistered", failureCategory(t, err)) declaration.ProtocolVersion = 0 - _, err = client.DeclareManager(t.Context(), managerRequest(created.ManagerID, created.Secret, declaration)) + _, err = client.DeclareManager(t.Context(), managerRequest(managerID, secret, declaration)) require.Error(t, err) assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) assert.Equal(t, "protocol_mismatch", failureCategory(t, err)) declaration.ProtocolVersion = 1 - declared, err := client.DeclareManager(t.Context(), managerRequest(created.ManagerID, created.Secret, declaration)) + declared, err := client.DeclareManager(t.Context(), managerRequest(managerID, secret, declaration)) require.NoError(t, err) assert.Positive(t, declared.Msg.GetHeartbeatIntervalMilliseconds()) assert.Positive(t, declared.Msg.GetRuntimeMetadataRefreshIntervalMilliseconds()) @@ -85,7 +86,7 @@ func TestManagerServiceProtocolAuthenticationAndDeclaration(t *testing.T) { assert.NotEmpty(t, declared.Msg.GetGiteaWebUrl()) manager := new(codespace_model.Manager) - has, err = db.GetEngine(t.Context()).ID(created.ManagerID).Get(manager) + has, err = db.GetEngine(t.Context()).ID(managerID).Get(manager) require.NoError(t, err) require.True(t, has) assert.Equal(t, "Gitea Manager", manager.Name) diff --git a/routers/web/codespace/action.go b/routers/web/codespace/action.go index c04f3c478da74..5466c0f151367 100644 --- a/routers/web/codespace/action.go +++ b/routers/web/codespace/action.go @@ -114,13 +114,11 @@ func AutoStop(ctx *context.Context) { var ok bool timeout, ok = parseAutoStopTimeoutForm(ctx) if !ok { - ctx.Flash.Error(ctx.Tr("codespace.auto_stop_invalid_duration")) - ctx.Redirect(returnPath, http.StatusSeeOther) + ctx.JSONErrorWithField(ctx.Tr("codespace.auto_stop_invalid_duration"), "timeout_value") return } default: - ctx.Flash.Error(ctx.Tr("codespace.error.invalid_request")) - ctx.Redirect(returnPath, http.StatusSeeOther) + ctx.JSONError(ctx.Tr("codespace.error.invalid_request")) return } _, err := codespace_service.UpdateAutoStop(ctx, codespace_service.UpdateAutoStopOptions{ @@ -131,15 +129,23 @@ func AutoStop(ctx *context.Context) { }) if err != nil { if errors.Is(err, codespace_service.ErrInteractionInvalidArgument) { - ctx.Flash.Error(ctx.Tr("codespace.auto_stop_invalid_range")) - ctx.Redirect(returnPath, http.StatusSeeOther) + ctx.JSONErrorWithField(ctx.Tr("codespace.auto_stop_invalid_range"), "timeout_value") return } - handleInteractionError(ctx, "UpdateAutoStop", err, returnPath) + switch { + case errors.Is(err, codespace_service.ErrInteractionNotFound), errors.Is(err, codespace_service.ErrInteractionPermissionDenied): + ctx.JSONErrorNotFound() + case errors.Is(err, codespace_service.ErrInteractionStateUnavailable): + ctx.JSONError(ctx.Tr("codespace.error.state_unavailable")) + case errors.Is(err, codespace_service.ErrInteractionVersionExhausted): + ctx.JSONError(ctx.Tr("codespace.error.version_exhausted")) + default: + ctx.JSONErrorAuto(err) + } return } ctx.Flash.Success(ctx.Tr("settings.saved_successfully")) - ctx.Redirect(returnPath, http.StatusSeeOther) + ctx.JSONRedirect(returnPath) } func handleInteractionError(ctx *context.Context, name string, err error, returnPath string) { diff --git a/routers/web/codespace/action_test.go b/routers/web/codespace/action_test.go index 61d30e232801d..4d408f3ff59fd 100644 --- a/routers/web/codespace/action_test.go +++ b/routers/web/codespace/action_test.go @@ -5,6 +5,7 @@ package codespace import ( "net/http" + "net/url" "testing" codespace_service "gitea.dev/services/codespace" @@ -14,6 +15,18 @@ import ( ) func TestCodespaceActionHelpers(t *testing.T) { + t.Run("detail return context", func(t *testing.T) { + for _, test := range []struct{ input, want string }{ + {"/-/codespaces?owner=org3&page=2", "/-/codespaces?owner=org3&page=2"}, + {"https://example.com/-/codespaces", "/-/codespaces"}, + {"/-/codespaces/99", "/-/codespaces"}, + } { + ctx, _ := contexttest.MockContext(t, "GET /-/codespaces/12?tab=logs&return_to="+url.QueryEscape(test.input)) + setCreatorDetailTab(ctx, &codespace_service.CreatorCodespaceView{ID: 12}) + assert.Equal(t, test.want, ctx.Data["CodespaceListReturnTo"]) + assert.Equal(t, "logs", ctx.Data["CodespaceTab"]) + } + }) t.Run("return path", func(t *testing.T) { tests := []struct { input string diff --git a/routers/web/codespace/create.go b/routers/web/codespace/create.go index c9b99cd08efed..afeae77911de0 100644 --- a/routers/web/codespace/create.go +++ b/routers/web/codespace/create.go @@ -21,6 +21,11 @@ type createPermissionRepository struct { Permissions []codespace_service.CreatePermissionRequest } +type createConfigurationGroup struct { + Scope string + Options []codespace_service.CreateDevContainerOption +} + // RepositoryRedirect redirects repository Codespace collection reads to the repository code page. func RepositoryRedirect(ctx *context.Context) { if ctx.Repo == nil || ctx.Repo.Repository == nil { @@ -46,6 +51,10 @@ func New(ctx *context.Context) { } plan, err := codespace_service.PrepareCodespace(ctx, opts) if err != nil { + if plan != nil && errors.Is(err, codespace_service.ErrCreateConfigurationInvalid) { + renderCreateConfirm(ctx, http.StatusUnprocessableEntity, plan, opts, ctx.Tr("codespace.configuration_invalid")) + return + } handleCreateError(ctx, err) return } @@ -69,6 +78,10 @@ func Create(ctx *context.Context) { } plan, err := codespace_service.PrepareCodespace(ctx, opts) if err != nil { + if plan != nil && errors.Is(err, codespace_service.ErrCreateConfigurationInvalid) { + renderCreateConfirm(ctx, http.StatusUnprocessableEntity, plan, opts, ctx.Tr("codespace.configuration_invalid")) + return + } handleCreateError(ctx, err) return } @@ -84,6 +97,11 @@ func Create(ctx *context.Context) { } result, err := codespace_service.CreateCodespace(ctx, opts) if err != nil { + if errors.Is(err, codespace_service.ErrCreateConfigurationInvalid) { + plan.RequestHash = "" + renderCreateConfirm(ctx, http.StatusUnprocessableEntity, plan, opts, ctx.Tr("codespace.configuration_invalid")) + return + } if errors.Is(err, codespace_service.ErrCreateEnvironmentUnavailable) || errors.Is(err, codespace_service.ErrCreateRequestChanged) { if currentPlan, prepareErr := codespace_service.PrepareCodespace(ctx, opts); prepareErr == nil { plan = currentPlan @@ -95,7 +113,7 @@ func Create(ctx *context.Context) { renderCreateConfirm(ctx, http.StatusUnprocessableEntity, plan, opts, errorMessage) return } - handleCreateError(ctx, err) + renderCreateConfirm(ctx, http.StatusUnprocessableEntity, plan, opts, ctx.Tr("codespace.error.invalid_create_request")) return } ctx.Redirect(setting.AppSubURL+codespaceDetailPath(result.CodespaceID), http.StatusSeeOther) @@ -123,10 +141,8 @@ func renderCreateConfirm(ctx *context.Context, status int, plan *codespace_servi permissionGrants[permission.FormName] = value } } - secretEnabled := make(map[string]bool, len(plan.RecommendedSecrets)) hasPendingRecommendedSecret := false for _, secret := range plan.RecommendedSecrets { - secretEnabled[secret.Name] = opts.RecommendedSecretEnabled[secret.Name] hasPendingRecommendedSecret = hasPendingRecommendedSecret || !secret.Available } @@ -135,9 +151,25 @@ func renderCreateConfirm(ctx *context.Context, status int, plan *codespace_servi ctx.Data["CreateSelectedEnvironment"] = selectedEnvironment ctx.Data["CreatePermissionRepositories"] = permissionRepositories ctx.Data["CreatePermissionGrants"] = permissionGrants - ctx.Data["CreateRecommendedSecretEnabled"] = secretEnabled + ctx.Data["CreateRecommendedSecretEnabled"] = opts.RecommendedSecretEnabled ctx.Data["CreateHasPendingRecommendedSecret"] = hasPendingRecommendedSecret ctx.Data["CreateError"] = errorMessage + groups := make([]createConfigurationGroup, 0, 3) + for _, scope := range []string{"repository", "personal", "site"} { + group := createConfigurationGroup{Scope: scope} + for _, option := range plan.DevContainerOptions { + if option.Scope == scope { + if option.Selected { + ctx.Data["CreateConfigurationSelected"] = true + } + group.Options = append(group.Options, option) + } + } + if len(group.Options) > 0 { + groups = append(groups, group) + } + } + ctx.Data["CreateConfigurationGroups"] = groups ctx.HTML(status, tplCodespaceCreateConfirm) } diff --git a/routers/web/codespace/settings.go b/routers/web/codespace/settings.go index 0cb445749a9fe..36fe7d0701928 100644 --- a/routers/web/codespace/settings.go +++ b/routers/web/codespace/settings.go @@ -51,13 +51,19 @@ func AdminManagerDelete(ctx *context.Context) { }) } +func AdminManagerUpdate(ctx *context.Context) { + handleManagerUpdate(ctx, codespace_service.ManagerSettingsOptions{Scope: codespace_service.ManagerSettingsScopeSite}) +} + +func AdminManagerSecret(ctx *context.Context) { + handleManagerSecret(ctx, codespace_service.ManagerSettingsOptions{Scope: codespace_service.ManagerSettingsScopeSite}) +} + // AdminManagersCreateManager creates a site-wide Manager identity. func AdminManagersCreateManager(ctx *context.Context) { handleManagerSettingsCreateManager(ctx, managerSettingsRenderOptions{ Scope: codespace_service.ManagerSettingsScopeSite, ActionBase: setting.AppSubURL + "/-/admin/codespaces/managers", - Template: tplAdminCodespaceManagers, - PageFlag: "PageIsAdminCodespaceManagers", }) } @@ -122,14 +128,20 @@ func UserManagerDelete(ctx *context.Context) { }) } +func UserManagerUpdate(ctx *context.Context) { + handleManagerUpdate(ctx, codespace_service.ManagerSettingsOptions{Scope: codespace_service.ManagerSettingsScopeUser, UserID: ctx.Doer.ID}) +} + +func UserManagerSecret(ctx *context.Context) { + handleManagerSecret(ctx, codespace_service.ManagerSettingsOptions{Scope: codespace_service.ManagerSettingsScopeUser, UserID: ctx.Doer.ID}) +} + // UserSettingsCreateManager creates a Manager identity owned by the current user. func UserSettingsCreateManager(ctx *context.Context) { handleManagerSettingsCreateManager(ctx, managerSettingsRenderOptions{ Scope: codespace_service.ManagerSettingsScopeUser, UserID: ctx.Doer.ID, ActionBase: setting.AppSubURL + "/user/settings/codespaces/managers", - Template: tplUserCodespaceSettings, - PageFlag: "PageIsCodespaceSettings", }) } @@ -187,6 +199,7 @@ func renderDevContainerTemplateSettings(ctx *context.Context, opts devContainerT ctx.Data["Title"] = ctx.Tr("codespace.dev_container_templates") ctx.Data[opts.PageFlag] = true ctx.Data["DevContainerTemplates"] = templates + ctx.Data["IsSiteTemplateSettings"] = opts.UserID == 0 ctx.Data["ActionBase"] = opts.ActionBase ctx.HTML(http.StatusOK, opts.Template) } @@ -199,10 +212,11 @@ func handleDevContainerTemplateUpsert(ctx *context.Context, opts devContainerTem Content: ctx.FormString("content"), }) if err != nil { - handleDevContainerTemplateActionError(ctx, opts.ActionBase, err) + handleDevContainerTemplateActionError(ctx, err) return } - ctx.Redirect(opts.ActionBase, http.StatusSeeOther) + ctx.Flash.Success(ctx.Tr("settings.saved_successfully")) + ctx.JSONRedirect(opts.ActionBase) } func handleDevContainerTemplateDelete(ctx *context.Context, opts devContainerTemplateRenderOptions) { @@ -211,19 +225,22 @@ func handleDevContainerTemplateDelete(ctx *context.Context, opts devContainerTem ID: ctx.PathParamInt64("template_id"), }) if err != nil { - handleDevContainerTemplateActionError(ctx, opts.ActionBase, err) + handleDevContainerTemplateActionError(ctx, err) return } ctx.JSONRedirect(opts.ActionBase) } -func handleDevContainerTemplateActionError(ctx *context.Context, redirectTo string, err error) { +func handleDevContainerTemplateActionError(ctx *context.Context, err error) { switch { case errors.Is(err, codespace_service.ErrDevContainerTemplateNotFound): - ctx.NotFound(nil) + ctx.JSONErrorNotFound() + case errors.Is(err, codespace_service.ErrDevContainerTemplateNameInvalid): + ctx.JSONErrorWithField(ctx.Tr("codespace.dev_container_template_name_invalid"), "name") + case errors.Is(err, codespace_service.ErrCreateConfigurationInvalid): + ctx.JSONErrorWithField(err.Error(), "content") default: - ctx.Flash.Error(err.Error()) - ctx.Redirect(redirectTo, http.StatusSeeOther) + ctx.JSONErrorAuto(err) } } @@ -313,7 +330,7 @@ func handleManagerDelete(ctx *context.Context, opts managerSettingsRenderOptions } func handleManagerSettingsCreateManager(ctx *context.Context, opts managerSettingsRenderOptions) { - result, err := codespace_service.CreateManager(ctx, codespace_service.CreateManagerOptions{ + managerID, err := codespace_service.CreateManager(ctx, codespace_service.CreateManagerOptions{ ManagerSettingsOptions: codespace_service.ManagerSettingsOptions{ Scope: opts.Scope, UserID: opts.UserID, @@ -321,16 +338,44 @@ func handleManagerSettingsCreateManager(ctx *context.Context, opts managerSettin Name: ctx.FormString("name"), }) if err != nil { - handleManagerSettingsActionError(ctx, opts.ActionBase, err) + handleManagerEditError(ctx, err) return } - if !populateManagerSettingsData(ctx, opts) { + ctx.JSONRedirect(opts.ActionBase + "/" + strconv.FormatInt(managerID, 10)) +} + +func handleManagerUpdate(ctx *context.Context, opts codespace_service.ManagerSettingsOptions) { + if err := codespace_service.UpdateManagerName(ctx, opts, ctx.PathParamInt64("manager_id"), ctx.FormString("name")); err != nil { + handleManagerEditError(ctx, err) return } - ctx.Data["NewManagerID"] = result.ManagerID - ctx.Data["NewManagerName"] = result.Name - ctx.Data["NewManagerSecret"] = result.Secret - ctx.HTML(http.StatusOK, opts.Template) + ctx.Flash.Success(ctx.Tr("settings.saved_successfully")) + ctx.JSONRedirect(ctx.Req.URL.Path) +} + +func handleManagerSecret(ctx *context.Context, opts codespace_service.ManagerSettingsOptions) { + ctx.RespHeader().Set("Cache-Control", "no-store") + secret, err := codespace_service.ResetManagerSecret(ctx, opts, ctx.PathParamInt64("manager_id"), ctx.FormString("confirm") == "reset-secret") + if err != nil { + handleManagerEditError(ctx, err) + return + } + ctx.JSON(http.StatusOK, map[string]string{"secret": secret}) +} + +func handleManagerEditError(ctx *context.Context, err error) { + switch { + case errors.Is(err, codespace_service.ErrManagerSettingsNotFound): + ctx.JSONErrorNotFound() + case errors.Is(err, codespace_service.ErrManagerSettingsNameInvalid): + ctx.JSONErrorWithField(ctx.Tr("codespace.manager_name_invalid"), "name") + case errors.Is(err, codespace_service.ErrManagerSettingsConfirmRequired): + ctx.JSONError(ctx.Tr("codespace.error.confirm_required")) + case errors.Is(err, codespace_service.ErrManagerSettingsChanged): + ctx.JSONError(ctx.Tr("codespace.manager_settings_changed")) + default: + ctx.JSONErrorAuto(err) + } } func handleManagerSettingsActionError(ctx *context.Context, redirectTo string, err error) { @@ -343,9 +388,6 @@ func handleManagerSettingsActionError(ctx *context.Context, redirectTo string, e case errors.Is(err, codespace_service.ErrManagerSettingsOwnershipConflict): ctx.Flash.Error(ctx.Tr("codespace.manager_ownership_conflict")) ctx.Redirect(redirectTo, http.StatusSeeOther) - case errors.Is(err, codespace_service.ErrManagerSettingsNameInvalid): - ctx.Flash.Error(ctx.Tr("codespace.manager_name_invalid")) - ctx.Redirect(redirectTo, http.StatusSeeOther) default: ctx.ServerError("CodespaceManagerSettingsAction", err) } diff --git a/routers/web/codespace/view.go b/routers/web/codespace/view.go index c686192e366af..5e93200edc894 100644 --- a/routers/web/codespace/view.go +++ b/routers/web/codespace/view.go @@ -6,9 +6,12 @@ package codespace import ( "errors" "net/http" + "net/url" + "strconv" "strings" "gitea.dev/models/organization" + "gitea.dev/modules/container" "gitea.dev/modules/setting" "gitea.dev/modules/templates" codespace_service "gitea.dev/services/codespace" @@ -83,8 +86,24 @@ func List(ctx *context.Context) { ctx.Data["Codespaces"] = result.Rows ctx.Data["CodespaceOwner"] = ownerName ctx.Data["CodespaceListReturnTo"] = setting.AppSubURL + codespaceListPath(ownerName, page) + stateURL, _ := url.Parse(setting.AppSubURL + codespaceListPath(ownerName, page)) + query := stateURL.Query() + query.Set("partial", "true") + stateURL.RawQuery = query.Encode() + ctx.Data["CodespaceListStateURL"] = stateURL.String() + refreshAfter := 15000 + for _, row := range result.Rows { + refreshAfter = min(refreshAfter, row.RefreshAfterMillis) + } + ctx.Data["CodespaceListRefreshAfter"] = refreshAfter pager := context.NewPagerBuilder(ctx).TotalCount(result.Total).PerPageLimit(pageSize).CurPage(page).Build() + pager.RemoveParam(container.SetOf("partial")) ctx.Data["Page"] = pager + ctx.RespHeader().Set("Cache-Control", "no-store") + if ctx.FormBool("partial") { + ctx.HTML(http.StatusOK, "codespace/list_state") + return + } ctx.HTML(http.StatusOK, tplCodespaceList) } @@ -145,6 +164,12 @@ func State(ctx *context.Context) { } func setCreatorDetailTab(ctx *context.Context, view *codespace_service.CreatorCodespaceView) { + listReturnTo := setting.AppSubURL + codespaceListPath("", 1) + if target, err := url.Parse(ctx.FormString("return_to")); err == nil && target.Scheme == "" && target.Host == "" && target.Fragment == "" && target.Path == listReturnTo { + page, _ := strconv.Atoi(target.Query().Get("page")) + listReturnTo = setting.AppSubURL + codespaceListPath(target.Query().Get("owner"), max(1, page)) + } + ctx.Data["CodespaceListReturnTo"] = listReturnTo tab := strings.TrimSpace(ctx.FormString("tab")) explicit := tab == codespace_service.DetailModeOverview || tab == codespace_service.DetailModeLogs if !explicit { @@ -152,6 +177,11 @@ func setCreatorDetailTab(ctx *context.Context, view *codespace_service.CreatorCo } ctx.Data["CodespaceTab"] = tab ctx.Data["CodespaceTabExplicit"] = explicit + query := url.Values{"return_to": {listReturnTo}} + if explicit { + query.Set("tab", tab) + } + ctx.Data["CodespaceDetailReturnTo"] = setting.AppSubURL + codespaceDetailPath(view.ID) + "?" + query.Encode() } func loadCreatorDetail(ctx *context.Context) (*codespace_service.CreatorCodespaceView, bool) { diff --git a/routers/web/codespace/view_test.go b/routers/web/codespace/view_test.go index ecd39b54110a0..37c874decf0f0 100644 --- a/routers/web/codespace/view_test.go +++ b/routers/web/codespace/view_test.go @@ -15,6 +15,7 @@ import ( "gitea.dev/modules/session" "gitea.dev/modules/templates" codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/context" "gitea.dev/services/contexttest" "github.com/stretchr/testify/assert" @@ -41,7 +42,7 @@ func TestListRendersCreatorCodespaces(t *testing.T) { require.True(t, ok) require.Len(t, rows, 1) assert.Equal(t, codespaceUUID, rows[0].UUID) - assert.Contains(t, resp.Body.String(), rows[0].ShortUUID) + assert.Contains(t, resp.Body.String(), "codespace-row-"+strconv.FormatInt(rows[0].ID, 10)) assert.Contains(t, resp.Body.String(), "context-user-switch") assert.NotNil(t, ctx.Data["Page"]) } @@ -54,7 +55,7 @@ func TestListFiltersCurrentCreatorByOrganizationRepositories(t *testing.T) { _, err := db.GetEngine(t.Context()).Where("uuid = ?", codespaceUUID).Cols("user_id", "repo_id").Update(&codespace_model.Codespace{UserID: 2, RepoID: 3}) require.NoError(t, err) - ctx, resp := contexttest.MockContext(t, "GET /-/codespaces?owner=org3", contexttest.MockContextOption{Render: templates.PageRenderer(), SessionStore: session.NewMockMemStore("codespace-org-list")}) + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces?owner=org3&partial=true", contexttest.MockContextOption{Render: templates.PageRenderer(), SessionStore: session.NewMockMemStore("codespace-org-list")}) contexttest.LoadUser(t, ctx, 2) List(ctx) @@ -64,6 +65,11 @@ func TestListFiltersCurrentCreatorByOrganizationRepositories(t *testing.T) { require.Len(t, rows, 1) assert.Equal(t, codespaceUUID, rows[0].UUID) assert.Equal(t, "org3", ctx.Data["CodespaceOwner"]) + assert.Equal(t, "/-/codespaces?owner=org3&partial=true", ctx.Data["CodespaceListStateURL"]) + pager, ok := ctx.Data["Page"].(*context.Pagination) + require.True(t, ok) + assert.Equal(t, "owner=org3", string(pager.GetParams())) + assert.Equal(t, "no-store", resp.Header().Get("Cache-Control")) } func TestDetailRendersCreatorCodespaceNoStore(t *testing.T) { diff --git a/routers/web/web.go b/routers/web/web.go index ae7986f9d905b..bbdc3842f5510 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -765,6 +765,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/codespaces/managers", web_codespace.UserSettings) m.Post("/codespaces/managers/create", web_codespace.UserSettingsCreateManager) m.Get("/codespaces/managers/{manager_id}", web_codespace.UserManager) + m.Post("/codespaces/managers/{manager_id}", web_codespace.UserManagerUpdate) + m.Post("/codespaces/managers/{manager_id}/secret", web_codespace.UserManagerSecret) m.Post("/codespaces/managers/{manager_id}/delete", web_codespace.UserManagerDelete) m.Combo("/codespaces/dev-container-templates").Get(web_codespace.UserDevContainerTemplates).Post(web_codespace.UserDevContainerTemplatePost) m.Post("/codespaces/dev-container-templates/{template_id}", web_codespace.UserDevContainerTemplateUpdate) @@ -894,6 +896,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/managers", web_codespace.AdminManagers) m.Post("/managers/create", web_codespace.AdminManagersCreateManager) m.Get("/managers/{manager_id}", web_codespace.AdminManager) + m.Post("/managers/{manager_id}", web_codespace.AdminManagerUpdate) + m.Post("/managers/{manager_id}/secret", web_codespace.AdminManagerSecret) m.Post("/managers/{manager_id}/delete", web_codespace.AdminManagerDelete) m.Combo("/dev-container-templates").Get(web_codespace.AdminDevContainerTemplates).Post(web_codespace.AdminDevContainerTemplatePost) m.Post("/dev-container-templates/{template_id}", web_codespace.AdminDevContainerTemplateUpdate) diff --git a/services/codespace/create.go b/services/codespace/create.go index d7c45dfca41a9..df16ce82907ec 100644 --- a/services/codespace/create.go +++ b/services/codespace/create.go @@ -150,11 +150,13 @@ var codespacePermissionUnits = map[string]unit_model.Type{ } // PrepareCodespace validates a creation request without changing persistent state. +// Invalid configurations return a reviewable plan without a creation hash alongside the error. func PrepareCodespace(ctx context.Context, opts CreateCodespaceOptions) (*CreateCodespacePlan, error) { prepared, err := prepareCodespace(ctx, opts) - if err != nil { + if err != nil && !errors.Is(err, ErrCreateConfigurationInvalid) { return nil, err } + configurationErr := err environments, err := listVisibleCreateEnvironments(ctx, opts.User.ID, opts.EnvironmentTag) if err != nil { return nil, err @@ -170,7 +172,7 @@ func PrepareCodespace(ctx context.Context, opts CreateCodespaceOptions) (*Create RecommendedSecrets: prepared.devContainer.RecommendedSecrets, AvailableSecrets: prepared.availableSecrets, SecretInjectionAllowed: prepared.secretInjectionAllowed, - }, nil + }, configurationErr } // CreateCodespace validates repository input and creates the initial Codespace row. @@ -309,6 +311,9 @@ func prepareCodespace(ctx context.Context, opts CreateCodespaceOptions) (*prepar } devContainer, options, err := prepareCreateDevContainer(ctx, opts.User, opts.Repo, gitRepo, sourceRef, opts.DevContainerSelection) if err != nil { + if errors.Is(err, ErrCreateConfigurationInvalid) { + return &preparedCodespace{sourceRef: sourceRef, devContainer: &createDevContainerPlan{}, devContainerOptions: options}, err + } return nil, err } // Fork pull requests execute code outside the source repository's trust boundary, so they never receive user secrets. @@ -516,15 +521,18 @@ func resolveCreatePermissions(ctx context.Context, user *user_model.User, source ownerName, repoName, _ := strings.Cut(fullName, "/") target, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName) if err != nil { + if repo_model.IsErrRepoNotExist(err) || user_model.IsErrUserNotExist(err) { + return nil, errors.Join(ErrCreateConfigurationInvalid, err) + } return nil, fmt.Errorf("resolve codespace permission repository %q: %w", fullName, err) } if target.ID == sourceRepo.ID { - return nil, fmt.Errorf("codespace permission repository %q is the source repository", fullName) + return nil, fmt.Errorf("%w: permission repository %q is the source repository", ErrCreateConfigurationInvalid, fullName) } for unitName, modeName := range units { unitType := codespacePermissionUnits[unitName] if !target.UnitEnabled(ctx, unitType) { - return nil, fmt.Errorf("codespace permission unit %q is not enabled for %q", unitName, fullName) + return nil, fmt.Errorf("%w: permission unit %q is not enabled for %q", ErrCreateConfigurationInvalid, unitName, fullName) } mode := perm_model.ParseAccessMode(modeName, perm_model.AccessModeRead, perm_model.AccessModeWrite) allowed, err := access_model.HasAccessUnit(ctx, user, target, unitType, mode) @@ -532,7 +540,7 @@ func resolveCreatePermissions(ctx context.Context, user *user_model.User, source return nil, err } if !allowed { - return nil, fmt.Errorf("user cannot grant %s access to %s for %q", modeName, unitName, fullName) + return nil, fmt.Errorf("%w: user cannot grant %s access to %s for %q", ErrCreateConfigurationInvalid, modeName, unitName, fullName) } permissions = append(permissions, CreatePermissionRequest{ RepositoryID: target.ID, RepositoryFullName: target.FullName(), UnitType: unitType, diff --git a/services/codespace/create_test.go b/services/codespace/create_test.go index d952d5e78e205..3e8b6e6d0307d 100644 --- a/services/codespace/create_test.go +++ b/services/codespace/create_test.go @@ -250,13 +250,17 @@ M 100644 inline .devcontainer/node/devcontainer.json data < 0 { - selection = configs[0].Path - } else if len(templateConfigs) > 0 { - selection = templateConfigs[0].Selection + } else if len(paths) > 0 { + selection = paths[0] + } else if len(templates) > 0 { + selection = devContainerTemplateSelectionPrefix + strconv.FormatInt(templates[0].ID, 10) } } + options := make([]CreateDevContainerOption, 0, len(paths)+len(templates)) + for _, configPath := range paths { + options = append(options, CreateDevContainerOption{ + Selection: configPath, + Name: configPath, + Scope: "repository", + Selected: selection == configPath, + }) + } + for _, template := range templates { + key := devContainerTemplateSelectionPrefix + strconv.FormatInt(template.ID, 10) + scope := "personal" + if template.UserID == 0 { + scope = "site" + } + options = append(options, CreateDevContainerOption{ + Selection: key, + Name: template.Name, + Scope: scope, + Selected: selection == key, + }) + } + var selected *createDevContainerPlan - if index := slices.IndexFunc(configs, func(config *createDevContainerPlan) bool { - return config.Path == selection + if slices.Contains(paths, selection) { + selected, err = loadRepositoryDevContainer(ctx, gitRepo, commit, selection) + } else if index := slices.IndexFunc(templates, func(template *codespace_model.DevContainerTemplate) bool { + return devContainerTemplateSelectionPrefix+strconv.FormatInt(template.ID, 10) == selection }); index >= 0 { - selected = configs[index] - } else if index := slices.IndexFunc(templateConfigs, func(config *createDevContainerPlan) bool { - return config.Selection == selection - }); index >= 0 { - selected = templateConfigs[index] + selected, err = loadTemplateDevContainer(templates[index]) + if err != nil { + err = errors.Join(ErrCreateConfigurationInvalid, err) + } } else { - return nil, nil, fmt.Errorf("Dev Container configuration %q is not available", selection) + err = ErrCreateConfigurationInvalid + } + if err != nil { + return nil, options, err } permissions, err := resolveCreatePermissions(ctx, user, repo, selected.PermissionRepositories) if err != nil { - return nil, nil, err + return nil, options, err } selected.Permissions = permissions - - options := make([]CreateDevContainerOption, 0, len(configs)+len(templateConfigs)) - for _, config := range configs { - options = append(options, CreateDevContainerOption{ - Selection: config.Path, - Name: config.Name, - Path: config.Path, - Selected: selection == config.Path, - }) - } - for _, config := range templateConfigs { - options = append(options, CreateDevContainerOption{ - Selection: config.Selection, - Name: config.Name, - Selected: selection == config.Selection, - }) - } return selected, options, nil } func discoverDevContainerPaths(ctx context.Context, gitRepo *git.Repository, commit *git.Commit) ([]string, error) { paths := make([]string, 0, 4) for _, configPath := range []string{devContainerPrimaryPath, devContainerRootPath} { - entry, err := commit.GetTreeEntryByPath(ctx, gitRepo, configPath) + _, err := commit.GetTreeEntryByPath(ctx, gitRepo, configPath) if err == nil { - if !entry.IsRegular() { - return nil, fmt.Errorf("Dev Container configuration %q must be a regular file", configPath) - } paths = append(paths, configPath) continue } @@ -203,16 +195,13 @@ func discoverDevContainerPaths(ctx context.Context, gitRepo *git.Repository, com continue } configPath := path.Join(".devcontainer", entry.Name(), "devcontainer.json") - configEntry, err := commit.GetTreeEntryByPath(ctx, gitRepo, configPath) + _, err := commit.GetTreeEntryByPath(ctx, gitRepo, configPath) if err != nil { if git.IsErrNotExist(err) || errors.Is(err, util.ErrNotExist) { continue } return nil, err } - if !configEntry.IsRegular() { - return nil, fmt.Errorf("Dev Container configuration %q must be a regular file", configPath) - } paths = append(paths, configPath) if len(paths) > maxDevContainerConfigurations { return nil, fmt.Errorf("repository has more than %d Dev Container configurations", maxDevContainerConfigurations) @@ -222,41 +211,43 @@ func discoverDevContainerPaths(ctx context.Context, gitRepo *git.Repository, com } func loadRepositoryDevContainer(ctx context.Context, gitRepo *git.Repository, commit *git.Commit, configPath string) (*createDevContainerPlan, error) { + entry, err := commit.GetTreeEntryByPath(ctx, gitRepo, configPath) + if err != nil { + return nil, err + } + if !entry.IsRegular() { + return nil, fmt.Errorf("%w: configuration %q must be a regular file", ErrCreateConfigurationInvalid, configPath) + } blob, err := commit.GetBlobByPath(ctx, gitRepo, configPath) if err != nil { return nil, err } if blob.Size(ctx) > devContainerConfigMaxSize { - return nil, fmt.Errorf("Dev Container configuration %q exceeds %d bytes", configPath, devContainerConfigMaxSize) + return nil, fmt.Errorf("%w: configuration %q exceeds %d bytes", ErrCreateConfigurationInvalid, configPath, devContainerConfigMaxSize) } content, err := blob.GetBlobBytes(ctx, devContainerConfigMaxSize+1) if err != nil { return nil, err } if int64(len(content)) > devContainerConfigMaxSize { - return nil, fmt.Errorf("Dev Container configuration %q exceeds %d bytes", configPath, devContainerConfigMaxSize) + return nil, fmt.Errorf("%w: configuration %q exceeds %d bytes", ErrCreateConfigurationInvalid, configPath, devContainerConfigMaxSize) } document, err := parseDevContainerDocument(content, configPath) if err != nil { - return nil, err - } - name := strings.TrimSpace(document.Name) - if name == "" { - name = configPath + return nil, errors.Join(ErrCreateConfigurationInvalid, err) } repositories, err := devContainerPermissionRepositories(document.Customizations) if err != nil { - return nil, fmt.Errorf("parse Dev Container configuration %q: %w", configPath, err) + return nil, fmt.Errorf("%w: parse Dev Container configuration %q: %w", ErrCreateConfigurationInvalid, configPath, err) } recommendedSecrets, err := parseRecommendedSecrets(document.Secrets) if err != nil { - return nil, fmt.Errorf("parse Dev Container configuration %q: %w", configPath, err) + return nil, fmt.Errorf("%w: parse Dev Container configuration %q: %w", ErrCreateConfigurationInvalid, configPath, err) } return &createDevContainerPlan{ Source: codespace_model.DevContainerSourceRepository, Selection: configPath, Path: configPath, - Name: name, PermissionRepositories: repositories, RecommendedSecrets: recommendedSecrets, }, nil @@ -282,7 +273,6 @@ func loadTemplateDevContainer(template *codespace_model.DevContainerTemplate) (* return &createDevContainerPlan{ Source: codespace_model.DevContainerSourceTemplate, Selection: devContainerTemplateSelectionPrefix + strconv.FormatInt(template.ID, 10), - Name: template.Name, Content: content, PermissionRepositories: repositories, RecommendedSecrets: recommendedSecrets, diff --git a/services/codespace/devcontainer_template.go b/services/codespace/devcontainer_template.go index 32a15056069d3..c972e7aef02cf 100644 --- a/services/codespace/devcontainer_template.go +++ b/services/codespace/devcontainer_template.go @@ -13,7 +13,10 @@ import ( "gitea.dev/models/db" ) -var ErrDevContainerTemplateNotFound = errors.New("codespace Dev Container template not found") +var ( + ErrDevContainerTemplateNotFound = errors.New("codespace Dev Container template not found") + ErrDevContainerTemplateNameInvalid = errors.New("invalid Dev Container template name") +) type DevContainerTemplateUpsertOptions struct { UserID int64 @@ -49,15 +52,12 @@ func ListDevContainerTemplates(ctx context.Context, userID int64) ([]*codespace_ func UpsertDevContainerTemplate(ctx context.Context, opts DevContainerTemplateUpsertOptions) error { name := strings.TrimSpace(opts.Name) content := strings.TrimSpace(opts.Content) - if name == "" { - return errors.New("Dev Container template name is required") - } - if len(name) > 255 { - return errors.New("Dev Container template name is too long") + if name == "" || len(name) > 255 { + return ErrDevContainerTemplateNameInvalid } template := &codespace_model.DevContainerTemplate{Name: name, Content: content} if _, err := loadTemplateDevContainer(template); err != nil { - return err + return errors.Join(ErrCreateConfigurationInvalid, err) } now := time.Now().Unix() return db.WithTx(ctx, func(ctx context.Context) error { diff --git a/services/codespace/open_token.go b/services/codespace/open_token.go index 50a0d9ba68757..bf0e4f18b5cf9 100644 --- a/services/codespace/open_token.go +++ b/services/codespace/open_token.go @@ -413,7 +413,7 @@ func advanceCodespaceInteraction(ctx context.Context, codespace *codespace_model func openEndpointInfo(codespace *codespace_model.Codespace, metadata runtimeMetadata, gatewayURL string, opts OpenEndpointOptions) (*openEndpointTarget, error) { endpoint, found := metadata.endpointByID(opts.EndpointID) if !found { - return unavailableOpenEndpoint(OpenTokenDeniedEndpointNotFound), nil + return &openEndpointTarget{unavailableCategory: OpenTokenDeniedEndpointNotFound}, nil } targetURL, err := gatewayEndpointURL(gatewayURL, codespace.UUID, opts.EndpointID) if err != nil { @@ -438,12 +438,6 @@ func openEndpointInfo(codespace *codespace_model.Codespace, metadata runtimeMeta return target, nil } -func unavailableOpenEndpoint(category string) *openEndpointTarget { - return &openEndpointTarget{ - unavailableCategory: category, - } -} - func validateOpenEndpointID(endpointID string) error { if endpointID == workspaceEndpointID || endpointIDPattern.MatchString(endpointID) { return nil diff --git a/services/codespace/settings.go b/services/codespace/settings.go index ce6ffec2ea9a3..7053f905eef06 100644 --- a/services/codespace/settings.go +++ b/services/codespace/settings.go @@ -33,6 +33,7 @@ var ( ErrManagerSettingsOwnershipConflict = errors.New("codespace manager contains a Codespace outside the owner scope") // ErrManagerSettingsNameInvalid is returned when a Manager display name cannot be stored. ErrManagerSettingsNameInvalid = errors.New("codespace manager name is invalid") + ErrManagerSettingsChanged = errors.New("codespace manager settings changed") ) // ManagerSettingsOptions selects site-wide or personal Codespace settings. @@ -55,13 +56,6 @@ type CreateManagerOptions struct { Name string } -// CreateManagerResult returns the Manager identity and one-time plaintext secret. -type CreateManagerResult struct { - ManagerID int64 - Name string - Secret string -} - // ManagerDetailOptions selects one Manager management page and its Codespace page. type ManagerDetailOptions struct { ManagerSettingsOptions @@ -86,6 +80,7 @@ type ManagerSettings struct { type ManagerSettingsView struct { ID int64 Name string + HasSecret bool UserID int64 UserDisplayName string Version string @@ -157,20 +152,20 @@ func GetManagerDetail(ctx context.Context, opts ManagerDetailOptions) (*ManagerD return &ManagerDetail{Manager: views[0], Codespaces: list.Rows, Total: list.Total}, nil } -// CreateManager creates a Manager identity and returns its secret once. -func CreateManager(ctx context.Context, opts CreateManagerOptions) (*CreateManagerResult, error) { +// CreateManager creates a Manager identity before credentials are provisioned. +func CreateManager(ctx context.Context, opts CreateManagerOptions) (int64, error) { name, err := normalizeManagerDisplayName(opts.Name) if err != nil { - return nil, err + return 0, err } if err := validateManagerSettingsScope(ctx, opts.ManagerSettingsOptions); err != nil { - return nil, err + return 0, err } userID := opts.UserID if opts.Scope == ManagerSettingsScopeSite { userID = 0 } - result := new(CreateManagerResult) + var managerID int64 err = globallock.LockAndDo(ctx, codespaceUserRelationLockKey(userID), func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error { if err := validateManagerSettingsScope(ctx, opts.ManagerSettingsOptions); err != nil { @@ -183,19 +178,75 @@ func CreateManager(ctx context.Context, opts CreateManagerOptions) (*CreateManag TagsJSON: "[]", CreatedUnix: time.Now().Unix(), } - result.Secret = manager.GenerateManagerSecret() if _, err := db.GetEngine(ctx).Insert(manager); err != nil { return err } - result.ManagerID = manager.ID - result.Name = manager.Name + managerID = manager.ID return nil }) }) if err != nil { + return 0, err + } + return managerID, nil +} + +// UpdateManagerName changes only the Gitea-managed display name. +func UpdateManagerName(ctx context.Context, opts ManagerSettingsOptions, managerID int64, name string) error { + name, err := normalizeManagerDisplayName(name) + if err != nil { + return err + } + manager, err := loadScopedSettingsManager(ctx, opts, managerID) + if err != nil { + return err + } + if manager.Name == name { + return nil + } + affected, err := db.GetEngine(ctx).Where("id = ? AND user_id = ?", manager.ID, manager.UserID). + Cols("name").Update(&codespace_model.Manager{Name: name}) + if err == nil && affected == 0 { + return ErrManagerSettingsChanged + } + return err +} + +// ResetManagerSecret returns the new secret only after replacing its stored verifier. +func ResetManagerSecret(ctx context.Context, opts ManagerSettingsOptions, managerID int64, confirm bool) (string, error) { + manager, err := loadScopedSettingsManager(ctx, opts, managerID) + if err != nil { + return "", err + } + if manager.SecretHash != "" && !confirm { + return "", ErrManagerSettingsConfirmRequired + } + previousHash := manager.SecretHash + secret := manager.GenerateManagerSecret() + // Reject a reset based on a verifier changed by another request. + affected, err := db.GetEngine(ctx).Where("id = ? AND user_id = ? AND secret_hash = ?", manager.ID, manager.UserID, previousHash). + Cols("secret_salt", "secret_hash").Update(manager) + if err != nil { + return "", err + } + if affected == 0 { + return "", ErrManagerSettingsChanged + } + return secret, nil +} + +func loadScopedSettingsManager(ctx context.Context, opts ManagerSettingsOptions, managerID int64) (*codespace_model.Manager, error) { + if err := validateManagerSettingsScope(ctx, opts); err != nil { return nil, err } - return result, nil + manager, err := loadSettingsManager(ctx, managerID) + if err != nil { + return nil, err + } + if !managerInSettingsScope(manager, opts.Scope, opts.UserID) { + return nil, ErrManagerSettingsNotFound + } + return manager, nil } func normalizeManagerDisplayName(name string) (string, error) { @@ -399,6 +450,7 @@ func settingsManagerViews(ctx context.Context, managers []*codespace_model.Manag view := &ManagerSettingsView{ ID: manager.ID, Name: manager.Name, + HasSecret: manager.SecretHash != "", UserID: manager.UserID, UserDisplayName: userName, Version: manager.Version, diff --git a/services/codespace/settings_test.go b/services/codespace/settings_test.go index eebb67464554f..628eb4ba002ef 100644 --- a/services/codespace/settings_test.go +++ b/services/codespace/settings_test.go @@ -14,20 +14,21 @@ import ( "github.com/stretchr/testify/require" ) -func TestCreateManagerReturnsOneTimeSecret(t *testing.T) { +func TestManagerNameAndCredentialLifecycle(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) opts := CreateManagerOptions{ ManagerSettingsOptions: ManagerSettingsOptions{Scope: ManagerSettingsScopeUser, UserID: 1}, Name: " Personal Manager ", } - result, err := CreateManager(t.Context(), opts) + managerID, err := CreateManager(t.Context(), opts) require.NoError(t, err) - require.NotZero(t, result.ManagerID) - assert.Equal(t, "Personal Manager", result.Name) - require.NotEmpty(t, result.Secret) - manager, err := AuthenticateManager(t.Context(), result.ManagerID, result.Secret) + require.NotZero(t, managerID) + manager, err := loadSettingsManager(t.Context(), managerID) require.NoError(t, err) + assert.Empty(t, manager.SecretHash) + _, err = AuthenticateManager(t.Context(), managerID, "not-issued") + require.ErrorIs(t, err, ErrManagerUnauthenticated) assert.Equal(t, "Personal Manager", manager.Name) assert.EqualValues(t, 1, manager.UserID) assert.Equal(t, codespace_model.ManagerRuntimeStateRecovering, manager.RuntimeState) @@ -35,6 +36,32 @@ func TestCreateManagerReturnsOneTimeSecret(t *testing.T) { require.NoError(t, err) require.Len(t, views.Managers, 1) assert.Equal(t, managerDisplayPending, views.Managers[0].RuntimeDisplayState) + assert.False(t, views.Managers[0].HasSecret) + secret, err := ResetManagerSecret(t.Context(), opts.ManagerSettingsOptions, managerID, false) + require.NoError(t, err) + _, err = AuthenticateManager(t.Context(), managerID, secret) + require.NoError(t, err) + beforeRename, err := loadSettingsManager(t.Context(), managerID) + require.NoError(t, err) + require.NoError(t, UpdateManagerName(t.Context(), opts.ManagerSettingsOptions, managerID, " Renamed ")) + manager, err = AuthenticateManager(t.Context(), managerID, secret) + require.NoError(t, err) + beforeRename.Name = "Renamed" + assert.Equal(t, beforeRename, manager) + assert.Equal(t, "Renamed", manager.Name) + assert.EqualValues(t, 1, manager.UserID) + _, err = ResetManagerSecret(t.Context(), opts.ManagerSettingsOptions, managerID, false) + require.ErrorIs(t, err, ErrManagerSettingsConfirmRequired) + otherUser := ManagerSettingsOptions{Scope: ManagerSettingsScopeUser, UserID: 2} + require.ErrorIs(t, UpdateManagerName(t.Context(), otherUser, managerID, "Other"), ErrManagerSettingsNotFound) + _, err = ResetManagerSecret(t.Context(), otherUser, managerID, true) + require.ErrorIs(t, err, ErrManagerSettingsNotFound) + newSecret, err := ResetManagerSecret(t.Context(), ManagerSettingsOptions{Scope: ManagerSettingsScopeSite}, managerID, true) + require.NoError(t, err) + _, err = AuthenticateManager(t.Context(), managerID, secret) + require.ErrorIs(t, err, ErrManagerUnauthenticated) + _, err = AuthenticateManager(t.Context(), managerID, newSecret) + require.NoError(t, err) } func TestCreateManagerRejectsInvalidName(t *testing.T) { diff --git a/services/codespace/token.go b/services/codespace/token.go index cbffe9dac6454..3868b7d1f69d0 100644 --- a/services/codespace/token.go +++ b/services/codespace/token.go @@ -295,7 +295,7 @@ func ResolveGiteaToken(ctx context.Context, token string) (*GiteaTokenAuthSnapsh if !strings.HasPrefix(token, codespaceTokenPrefix) { return nil, ErrResolveGiteaTokenUnmatched } - if !validCodespaceTokenPlaintext(token) { + if !IsGiteaTokenPlaintext(token) { return nil, ErrResolveGiteaTokenRejected } if !setting.Codespace.Enabled { @@ -422,7 +422,7 @@ func readCurrentGiteaToken(ctx context.Context, codespaceID int64) (string, bool return "", false, err } token, err := secret_module.DecryptSecret(setting.SecretKey, row.TokenEncrypted) - if err != nil || !validCodespaceTokenPlaintext(token) || !verifyCodespaceGiteaToken(row, token) { + if err != nil || !verifyCodespaceGiteaToken(row, token) { if _, deleteErr := db.GetEngine(ctx).ID(codespaceID).Delete(new(codespace_model.GiteaToken)); deleteErr != nil { return "", false, deleteErr } @@ -441,11 +441,11 @@ func hasValidCurrentGiteaToken(ctx context.Context, codespaceID int64) (bool, er if err != nil { return false, nil } - return validCodespaceTokenPlaintext(token) && verifyCodespaceGiteaToken(row, token), nil + return verifyCodespaceGiteaToken(row, token), nil } func insertNewGiteaToken(ctx context.Context, codespaceID int64) (string, error) { - token := generateCodespaceGiteaToken() + token := codespaceTokenPrefix + hex.EncodeToString(util.CryptoRandomBytes(32)) salt := util.CryptoRandomString(10) encrypted, err := secret_module.EncryptSecret(setting.SecretKey, token) if err != nil { @@ -475,21 +475,13 @@ func verifyCodespaceGiteaToken(row *codespace_model.GiteaToken, token string) bo if row == nil || row.TokenHash == "" || row.TokenSalt == "" { return false } - if !validCodespaceTokenPlaintext(token) || row.TokenLastEight != token[len(token)-8:] { + if !IsGiteaTokenPlaintext(token) || row.TokenLastEight != token[len(token)-8:] { return false } hash := auth_model.HashToken(token, row.TokenSalt) return subtle.ConstantTimeCompare([]byte(row.TokenHash), []byte(hash)) == 1 } -func generateCodespaceGiteaToken() string { - return codespaceTokenPrefix + hex.EncodeToString(util.CryptoRandomBytes(32)) -} - -func validCodespaceTokenPlaintext(token string) bool { - return IsGiteaTokenPlaintext(token) -} - // IsGiteaTokenPlaintext reports whether token has the Codespace Token plaintext format. func IsGiteaTokenPlaintext(token string) bool { if !IsGiteaTokenCandidate(token) { diff --git a/services/codespace/view.go b/services/codespace/view.go index a908d65f7940c..8b114a1ef5ca4 100644 --- a/services/codespace/view.go +++ b/services/codespace/view.go @@ -418,6 +418,9 @@ func applyCreatorDisplayState(ctx context.Context, codespace *codespace_model.Co view.StatusSummary = statusSummary(view.DisplayStatus) view.DisplayStatusKey = "codespace.status." + view.DisplayStatus view.StatusSummaryKey = "codespace.status_summary." + view.DisplayStatus + if view.DisplayStatus == DisplayStopped && (manager == nil || manager.RuntimeState != codespace_model.ManagerRuntimeStateOnline || isManagerOffline(manager)) { + view.StatusSummaryKey = "codespace.stopped_manager_unavailable" + } } func runningDisplayStatus(ctx context.Context, codespace *codespace_model.Codespace, view *CreatorCodespaceView, manager *codespace_model.Manager, includeDetailData bool) string { diff --git a/services/codespace/view_test.go b/services/codespace/view_test.go index 7c0d88c8dcc6c..6bcc74bfba930 100644 --- a/services/codespace/view_test.go +++ b/services/codespace/view_test.go @@ -312,6 +312,7 @@ func TestStoppedCreatorCodespaceResumeRequiresOnlineManager(t *testing.T) { require.NoError(t, err) assert.Equal(t, DisplayStopped, offlineView.DisplayStatus) assert.False(t, offlineView.CanResume) + assert.Equal(t, "codespace.stopped_manager_unavailable", offlineView.StatusSummaryKey) markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) onlineUUID := "21212121-2121-4121-8121-212121212121" @@ -324,6 +325,7 @@ func TestStoppedCreatorCodespaceResumeRequiresOnlineManager(t *testing.T) { require.NoError(t, err) assert.Equal(t, DisplayStopped, onlineView.DisplayStatus) assert.True(t, onlineView.CanResume) + assert.Equal(t, "codespace.status_summary.stopped", onlineView.StatusSummaryKey) _, err = db.GetEngine(t.Context()).ID(manager.ID).Cols("runtime_state").Update(&codespace_model.Manager{ RuntimeState: codespace_model.ManagerRuntimeStateRecovering, diff --git a/templates/codespace/create_confirm.tmpl b/templates/codespace/create_confirm.tmpl index c4cad5365d9bf..85dccc78bff19 100644 --- a/templates/codespace/create_confirm.tmpl +++ b/templates/codespace/create_confirm.tmpl @@ -63,7 +63,7 @@
{{range $environment := .CreatePlan.Environments}} {{if not $environment.Online}} -
{{ctx.Locale.Tr "codespace.environment_offline_queue"}}
+
{{ctx.Locale.Tr "codespace.environment_offline_queue"}}
{{end}} {{end}}
@@ -71,15 +71,20 @@ - {{range $option := .CreatePlan.DevContainerOptions}} - {{if and $option.Selected $option.Path}}
{{$option.Path}}
{{end}} - {{end}} +
{{ctx.Locale.Tr "codespace.configuration_review_warning"}}
+
+ {{if .CreatePlan.RequestHash}}

{{ctx.Locale.Tr "codespace.repository_access"}}

{{if .CreatePermissionRepositories}} @@ -165,13 +170,14 @@ {{end}} {{end}}
+ {{end}}
{{ctx.Locale.Tr "cancel"}} - +
{{end}} diff --git a/templates/codespace/detail.tmpl b/templates/codespace/detail.tmpl index 7991c6986585a..53e9f39e0baf7 100644 --- a/templates/codespace/detail.tmpl +++ b/templates/codespace/detail.tmpl @@ -2,7 +2,8 @@
{{template "base/alert" .}} - {{svg "octicon-arrow-left" 14}} {{ctx.Locale.Tr "codespace.back_to_list"}} + {{svg "octicon-arrow-left" 14}} {{ctx.Locale.Tr "codespace.back_to_list"}} + {{template "codespace/refresh_notice" .}} {{if .OpenEndpointError}}
{{ctx.Locale.Tr "codespace.open.unavailable"}}
{{end}} {{template "codespace/open_modal" .}} {{template "codespace/settings_modal" .}} diff --git a/templates/codespace/devcontainer_templates_content.tmpl b/templates/codespace/devcontainer_templates_content.tmpl index 6e2eede71121c..4c678da41f7e8 100644 --- a/templates/codespace/devcontainer_templates_content.tmpl +++ b/templates/codespace/devcontainer_templates_content.tmpl @@ -1,31 +1,35 @@

{{ctx.Locale.Tr "codespace.dev_container_templates"}}
-

+

{{if .IsSiteTemplateSettings}}{{ctx.Locale.Tr "codespace.dev_container_template_site_help"}}{{else}}{{ctx.Locale.Tr "codespace.dev_container_template_personal_help"}}{{end}}

{{if .DevContainerTemplates}}
{{range .DevContainerTemplates}}
-
{{svg "octicon-file-code" 32}}
-
{{.Name}}
-
{{DateUtils.AbsoluteShort .UpdatedUnix}}
+
{{.Name}}
+
{{ctx.Locale.Tr "codespace.updated"}} {{DateUtils.TimeSince .UpdatedUnix}}
-
-
{{$.CsrfTokenHtml}} -
@@ -46,21 +50,26 @@ {{end}}
-