diff --git a/assets/go-licenses.json b/assets/go-licenses.json index 73a00dc98226c..db9ed34921eff 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -49,6 +49,11 @@ "path": "gitea.dev/actionslib/LICENSE", "licenseText": "MIT License\n\nCopyright (c) 2022 The Gitea Authors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" }, + { + "name": "gitea.dev/codespace-proto-go", + "path": "gitea.dev/codespace-proto-go/LICENSE", + "licenseText": "Copyright (c) 2026 The Gitea Authors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n" + }, { "name": "gitea.dev/modules/lfs", "path": "gitea.dev/modules/lfs/LICENSE", @@ -984,6 +989,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 c6521e1590a1c..f929b117c9108 100644 --- a/cmd/serv.go +++ b/cmd/serv.go @@ -173,12 +173,16 @@ func runServ(ctx context.Context, c *cli.Command) error { } var authSuccessMsg string switch key.Type { + case asymkey_model.KeyTypeUser: + authSuccessMsg = "Hi there, " + user.Name + "! You've successfully authenticated with the SSH key named " + key.Name + "." case asymkey_model.KeyTypeDeploy: authSuccessMsg = "Hi there! You've successfully authenticated with an SSH deploy key." case asymkey_model.KeyTypePrincipal: authSuccessMsg = "Hi there! You've successfully authenticated with the SSH principal " + key.Content + "." + case asymkey_model.KeyTypeCodespace: + authSuccessMsg = "Hi there! You've successfully authenticated with the codespace key named " + key.Name + "." default: - authSuccessMsg = "Hi there, " + user.Name + "! You've successfully authenticated with the SSH key named " + key.Name + "." + return fail(ctx, "Unsupported key type", "Unsupported key type %d for key %d", key.Type, key.ID) } _, _ = fmt.Fprintf(c.ErrWriter, "%s\n%s", authSuccessMsg, diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index a1462aa049978..61b30a3f84977 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2217,6 +2217,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 @@ -3107,6 +3121,54 @@ 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 +;; +;; 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 +;; +;; 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 +;; +;; 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 1d5fdd73bd0d7..fcf87a22ece1d 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +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 v1.2.1 + gitea.dev/codespace-proto-go v0.0.0-20260907082402-c2857d30f25f gitea.dev/sdk v1.2.0 github.com/42wim/httpsig v1.2.4 github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 @@ -55,6 +56,7 @@ require ( github.com/google/go-github/v92 v92.0.0 github.com/google/licenseclassifier/v2 v2.0.0 github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe + github.com/google/uuid v1.6.0 github.com/gorilla/feeds v1.2.0 github.com/gorilla/sessions v1.4.0 github.com/hashicorp/go-version v1.9.0 @@ -86,6 +88,7 @@ require ( github.com/sergi/go-diff v1.4.0 github.com/stretchr/testify v1.12.1 github.com/syndtr/goleveldb v1.0.0 + github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f github.com/ulikunitz/xz v0.5.17 github.com/urfave/cli-docs/v3 v3.1.0 github.com/urfave/cli/v3 v3.13.0 @@ -181,7 +184,6 @@ require ( github.com/google/flatbuffers v25.12.19+incompatible // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect diff --git a/go.sum b/go.sum index e97f448a4d2ba..42a53b724e387 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +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 v1.2.1 h1:GL//K/0zIZV6h1OOksv1awvFVg0rg7fug2xWcG7mkG0= gitea.dev/actionslib v1.2.1/go.mod h1:1+gqOKGSEPn2IFHgY8S3GC5Ld+Hn8jF3OxiFHQ/fpx4= +gitea.dev/codespace-proto-go v0.0.0-20260907082402-c2857d30f25f h1:rcXIwyXtQUQ+IUFgTzPqrw3byaB6K5IZhNTnXD9rUeo= +gitea.dev/codespace-proto-go v0.0.0-20260907082402-c2857d30f25f/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= @@ -614,6 +616,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.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index 694cf0257c68e..66ade460a195c 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -429,6 +429,7 @@ func prepareMigrationTasks() []*migration { newMigration(353, "Add audit event table", v28.AddAuditEventTable), newMigration(354, "Add Actions job queue indexes", v28.AddActionQueueIndexes), newMigration(355, "Add AutoMerge merged_commit_id column", v28.AddAutoMergeMergedCommitID), + newMigration(356, "Add Codespace tables", v28.AddCodespaceTables), } return preparedMigrations } diff --git a/modelmigration/v28/v356.go b/modelmigration/v28/v356.go new file mode 100644 index 0000000000000..ec2b5a14060ac --- /dev/null +++ b/modelmigration/v28/v356.go @@ -0,0 +1,198 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v28 + +import ( + "context" + "time" + + "gitea.dev/modelmigration/base" + + "xorm.io/xorm/schemas" +) + +type codespace struct { + ID int64 + UUID string `xorm:"VARCHAR(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 ''"` + RefName string `xorm:"TEXT NOT NULL"` + EnvironmentTag string `xorm:"VARCHAR(64) NOT NULL"` + CommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + DevContainerSource string `xorm:"VARCHAR(32) NOT NULL DEFAULT ''"` + DevContainerPath string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"` + DevContainerContent string `xorm:"TEXT NOT NULL"` + 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", "id") + + 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", "id") + + managerActive := schemas.NewIndex("manager_active", schemas.IndexType) + managerActive.AddColumn("manager_id", "operation_status", "operation_created_unix", "id") + + queuedTimeout := schemas.NewIndex("queued_timeout", schemas.IndexType) + queuedTimeout.AddColumn("operation_status", "operation_created_unix", "id") + + runningTimeout := schemas.NewIndex("running_timeout", schemas.IndexType) + runningTimeout.AddColumn("operation_status", "operation_deadline_unix", "id") + + failedRetention := schemas.NewIndex("failed_retention", schemas.IndexType) + failedRetention.AddColumn("status", "updated_unix", "id") + + 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(_ 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 '' index(kind_address)"` + Address string `xorm:"VARCHAR(512) NOT NULL DEFAULT '' index(kind_address)"` + } + + type codespaceGiteaToken struct { + CodespaceID int64 `xorm:"pk"` + 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 { + CodespaceID int64 `xorm:"pk"` + 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 { + AuthorizationID int64 `xorm:"pk NOT NULL"` + TargetRepoID int64 `xorm:"pk NOT NULL index"` + UnitType int `xorm:"pk NOT NULL"` + 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 { + SecretID int64 `xorm:"pk NOT NULL"` + RepoID int64 `xorm:"pk NOT NULL index"` + } + + type codespaceDevContainerTemplate struct { + ID int64 + UserID int64 `xorm:"NOT NULL DEFAULT 0 index"` + Name string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"` + Content string `xorm:"TEXT NOT NULL"` + CreatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + UpdatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + } + + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + if err := sess.Sync( + new(codespace), + new(codespaceManager), + new(codespaceManagerAddress), + new(codespaceGiteaToken), + new(codespaceSSHKey), + new(codespacePermissionAuthorization), + new(codespacePermissionRepository), + new(codespaceUserSecret), + new(codespaceUserSecretRepository), + new(codespaceDevContainerTemplate), + ); err != nil { + _ = sess.Rollback() + return err + } + now := time.Now().Unix() + if _, err := sess.Insert(&codespaceDevContainerTemplate{ + UserID: 0, + Name: "Default", + Content: `{"image":"mcr.microsoft.com/devcontainers/base:ubuntu"}`, + CreatedUnix: now, + UpdatedUnix: now, + }); err != nil { + _ = sess.Rollback() + return err + } + return sess.Commit() +} diff --git a/modelmigration/v28/v356_test.go b/modelmigration/v28/v356_test.go new file mode 100644 index 0000000000000..36d5be4c1a574 --- /dev/null +++ b/modelmigration/v28/v356_test.go @@ -0,0 +1,129 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v28 + +import ( + "context" + "slices" + "testing" + + "gitea.dev/modelmigration/base" + "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(t.Context(), x)) + + for _, table := range []string{ + "codespace", + "codespace_manager", + "codespace_manager_address", + "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", "id")) + assert.True(t, hasIndex(codespaceIndexes, "repo_id")) + 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")) + assert.True(t, hasIndex(codespaceIndexes, "operation_status", "operation_created_unix", "id")) + assert.True(t, hasIndex(codespaceIndexes, "operation_status", "operation_deadline_unix", "id")) + assert.True(t, hasIndex(codespaceIndexes, "status", "updated_unix", "id")) + + 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, hasIndex(addressIndexes, "kind", "address")) + assertPrimaryKeyColumns(t, x, "codespace_manager_address", "manager_id", "kind") + + giteaTokenIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_gitea_token") + require.NoError(t, err) + assert.True(t, hasUniqueIndex(giteaTokenIndexes, "token_hash")) + assertPrimaryKeyColumns(t, x, "codespace_gitea_token", "codespace_id") + + sshKeyIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "codespace_ssh_key") + require.NoError(t, err) + assert.True(t, hasUniqueIndex(sshKeyIndexes, "key_id")) + assertPrimaryKeyColumns(t, x, "codespace_ssh_key", "codespace_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, hasIndex(repositoryIndexes, "target_repo_id")) + assertPrimaryKeyColumns(t, x, "codespace_permission_repository", "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, "repo_id")) + assertPrimaryKeyColumns(t, x, "codespace_user_secret_repository", "secret_id", "repo_id") +} + +func assertPrimaryKeyColumns(t *testing.T, x base.EngineMigration, table string, columns ...string) { + t.Helper() + _, tableColumns, err := x.Dialect().GetColumns(x.DB(), context.Background(), table) + require.NoError(t, err) + primaryKeys := make([]string, 0, len(columns)) + for name, column := range tableColumns { + if column.IsPrimaryKey { + primaryKeys = append(primaryKeys, name) + } + } + slices.Sort(primaryKeys) + expected := slices.Clone(columns) + slices.Sort(expected) + assert.Equal(t, expected, primaryKeys) +} + +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 be2484a53426c..75b75ad4a98f4 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"` // deploy-key doesn't have owner @@ -128,37 +131,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 = addPublicKey(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 = addPublicKey(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 1a45f5c4390c6..30c78cf7b333b 100644 --- a/models/asymkey/ssh_key_authorized_keys.go +++ b/models/asymkey/ssh_key_authorized_keys.go @@ -72,13 +72,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 @@ -86,6 +87,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 @@ -151,8 +154,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 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_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..3973215541814 --- /dev/null +++ b/models/codespace/codespace.go @@ -0,0 +1,405 @@ +// 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 Dev Container sources. +const ( + DevContainerSourceRepository = "repository" + DevContainerSourceTemplate = "template" +) + +// 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 { + ID int64 + UUID string `xorm:"VARCHAR(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 ''"` + RefName string `xorm:"TEXT NOT NULL"` + EnvironmentTag string `xorm:"VARCHAR(64) NOT NULL"` + CommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + DevContainerSource string `xorm:"VARCHAR(32) NOT NULL DEFAULT ''"` + DevContainerPath string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"` + DevContainerContent string `xorm:"TEXT NOT NULL"` + 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 { + ManagerID 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. +type GiteaToken struct { + CodespaceID int64 `xorm:"pk"` + 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 { + CodespaceID int64 `xorm:"pk"` + KeyID int64 `xorm:"NOT NULL UNIQUE"` +} + +// DevContainerTemplate stores a manually named non-repository Dev Container configuration. +type DevContainerTemplate struct { + ID int64 + UserID int64 `xorm:"NOT NULL DEFAULT 0 index"` + Name string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"` + Content string `xorm:"TEXT NOT NULL"` + CreatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` + UpdatedUnix int64 `xorm:"NOT NULL DEFAULT 0"` +} + +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", "id") + + 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", "id") + + managerActive := schemas.NewIndex("manager_active", schemas.IndexType) + managerActive.AddColumn("manager_id", "operation_status", "operation_created_unix", "id") + + queuedTimeout := schemas.NewIndex("queued_timeout", schemas.IndexType) + queuedTimeout.AddColumn("operation_status", "operation_created_unix", "id") + + runningTimeout := schemas.NewIndex("running_timeout", schemas.IndexType) + runningTimeout.AddColumn("operation_status", "operation_deadline_unix", "id") + + failedRetention := schemas.NewIndex("failed_retention", schemas.IndexType) + failedRetention.AddColumn("status", "updated_unix", "id") + + 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 (*GiteaToken) TableName() string { + return "codespace_gitea_token" +} + +func (*SSHKey) TableName() string { + return "codespace_ssh_key" +} + +func (*DevContainerTemplate) TableName() string { + return "codespace_dev_container_template" +} + +func init() { + db.RegisterModel(new(Codespace)) + db.RegisterModel(new(Manager)) + db.RegisterModel(new(ManagerAddress)) + db.RegisterModel(new(GiteaToken)) + db.RegisterModel(new(SSHKey)) + db.RegisterModel(new(DevContainerTemplate)) +} + +// 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 codespace.UUID != "" { + 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) + } + switch codespace.DevContainerSource { + case DevContainerSourceRepository: + if strings.TrimSpace(codespace.DevContainerPath) == "" || strings.TrimSpace(codespace.DevContainerContent) != "" { + return errors.New("invalid Dev Container configuration") + } + case DevContainerSourceTemplate: + if strings.TrimSpace(codespace.DevContainerPath) != "" || strings.TrimSpace(codespace.DevContainerContent) == "" { + return errors.New("invalid Dev Container configuration") + } + default: + 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..24e279dd108d5 --- /dev/null +++ b/models/codespace/codespace_test.go @@ -0,0 +1,196 @@ +// 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", "id") + 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", "id") + assertIndexColumns(t, (&Codespace{}).TableIndices(), "manager_active", "manager_id", "operation_status", "operation_created_unix", "id") + assertIndexColumns(t, (&Codespace{}).TableIndices(), "queued_timeout", "operation_status", "operation_created_unix", "id") + assertIndexColumns(t, (&Codespace{}).TableIndices(), "running_timeout", "operation_status", "operation_deadline_unix", "id") + assertIndexColumns(t, (&Codespace{}).TableIndices(), "failed_retention", "status", "updated_unix", "id") +} + +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.DevContainerSource = DevContainerSourceRepository + row.DevContainerPath = ".devcontainer/devcontainer.json" + row.DevContainerContent = "" + require.NoError(t, ValidateCodespace(row)) + + row = validCodespace("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + row.DevContainerSource = DevContainerSourceTemplate + row.DevContainerPath = "" + row.DevContainerContent = `{"image":"debian:12"}` + 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", + DevContainerSource: DevContainerSourceTemplate, + DevContainerContent: `{"image":"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..d0c629f44b267 --- /dev/null +++ b/models/codespace/permission.go @@ -0,0 +1,43 @@ +// 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 { + AuthorizationID int64 `xorm:"pk NOT NULL"` + TargetRepoID int64 `xorm:"pk NOT NULL index"` + UnitType unit.Type `xorm:"pk NOT NULL"` + 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..0101560da18e5 --- /dev/null +++ b/models/codespace/secret.go @@ -0,0 +1,37 @@ +// 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 { + SecretID int64 `xorm:"pk NOT NULL"` + RepoID int64 `xorm:"pk NOT NULL index"` +} + +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/models/deploykey/deploykey_ssh.go b/models/deploykey/deploykey_ssh.go index c072a4f26c1bc..aa3fd33cb3f89 100644 --- a/models/deploykey/deploykey_ssh.go +++ b/models/deploykey/deploykey_ssh.go @@ -9,6 +9,7 @@ import ( "gitea.dev/models/asymkey" "gitea.dev/models/db" "gitea.dev/models/perm" + "gitea.dev/modules/globallock" "gitea.dev/modules/util" "xorm.io/builder" @@ -19,23 +20,33 @@ func AddDeployKeySSH(ctx context.Context, repoID int64, name, content string, ac if accessMode != perm.AccessModeRead && accessMode != perm.AccessModeWrite { return nil, util.NewInvalidArgumentErrorf("invalid access mode") } - return db.WithTx2(ctx, func(ctx context.Context) (*DeployKey, error) { - pkey, err := asymkey.FindOrAddDeployPublicKey(ctx, content) - if err != nil { - return nil, err - } - if has, err := db.Exist[DeployKey](ctx, builder.Eq{"repo_id": repoID, "key_id": pkey.ID}); err != nil { - return nil, err - } else if has { - return nil, ErrDeployKeyAlreadyExist{pkey.ID, repoID} - } - if err := checkDeployKeyName(ctx, repoID, name); err != nil { - return nil, err - } + fingerprint, err := asymkey.CalcFingerprint(content) + if err != nil { + return nil, err + } + var key *DeployKey + err = globallock.LockAndDo(ctx, asymkey.PublicKeyFingerprintLockKey(fingerprint), func(ctx context.Context) error { + createdKey, err := db.WithTx2(ctx, func(ctx context.Context) (*DeployKey, error) { + pkey, err := asymkey.FindOrAddDeployPublicKey(ctx, content) + if err != nil { + return nil, err + } + if has, err := db.Exist[DeployKey](ctx, builder.Eq{"repo_id": repoID, "key_id": pkey.ID}); err != nil { + return nil, err + } else if has { + return nil, ErrDeployKeyAlreadyExist{pkey.ID, repoID} + } + if err := checkDeployKeyName(ctx, repoID, name); err != nil { + return nil, err + } - key := &DeployKey{KeyID: pkey.ID, RepoID: repoID, KeyType: KeyTypeSSH, Name: name, Fingerprint: pkey.Fingerprint, Mode: accessMode} - return key, db.Insert(ctx, key) + key := &DeployKey{KeyID: pkey.ID, RepoID: repoID, KeyType: KeyTypeSSH, Name: name, Fingerprint: pkey.Fingerprint, Mode: accessMode} + return key, db.Insert(ctx, key) + }) + key = createdKey + return err }) + return key, err } func (key *DeployKey) LoadPublicKey(ctx context.Context) (err error) { diff --git a/modules/setting/codespace.go b/modules/setting/codespace.go new file mode 100644 index 0000000000000..b92d0f4379c5b --- /dev/null +++ b/modules/setting/codespace.go @@ -0,0 +1,69 @@ +// 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 + ManagerOfflineTimeout time.Duration + OperationLeaseTimeout time.Duration + OperationMaxDuration time.Duration + QueueTimeout time.Duration + LogMaxSize int64 + AutoStopDefaultTimeout time.Duration + AutoStopMinTimeout time.Duration + AutoStopMaxTimeout time.Duration +}{ + Enabled: true, + GitProtocol: "http", + GitSSHKnownHosts: nil, + GatewayRequireHTTPS: false, + ControlPlaneTimeout: 30 * time.Second, + ManagerOfflineTimeout: 120 * time.Second, + OperationLeaseTimeout: 300 * time.Second, + OperationMaxDuration: 2 * time.Hour, + QueueTimeout: 5 * time.Minute, + LogMaxSize: 64 * 1024 * 1024, + 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.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.LogMaxSize = mustCodespaceBytes(sec, "LOG_MAX_SIZE", "64MiB") + 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) + Codespace.GitProtocol = strings.ToLower(strings.TrimSpace(sec.Key("GIT_PROTOCOL").MustString("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..343b18e96a0bf --- /dev/null +++ b/modules/setting/codespace_test.go @@ -0,0 +1,57 @@ +// 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 +MANAGER_OFFLINE_TIMEOUT = 80s +OPERATION_LEASE_TIMEOUT = 1500ms +OPERATION_MAX_DURATION = 3h +QUEUE_TIMEOUT = 7m +LOG_MAX_SIZE = 32MiB +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.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.EqualValues(t, 32*1024*1024, Codespace.LogMaxSize) + 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, "invalid", Codespace.GitProtocol) + assert.False(t, Codespace.GatewayRequireHTTPS) + assert.Equal(t, 30*time.Second, Codespace.ControlPlaneTimeout) +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 94d3432ad2d99..bab3262658e3f 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -151,6 +151,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/modules/web/routing/logger.go b/modules/web/routing/logger.go index 4a94b71d6df8d..efdfd0f9784ea 100644 --- a/modules/web/routing/logger.go +++ b/modules/web/routing/logger.go @@ -84,8 +84,20 @@ func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) { logLevel = log.INFO } // lower the log level for some specific requests, in most cases these logs are not useful - if status > 0 && status < 400 && - req.RequestURI == "/api/actions/runner.v1.RunnerService/FetchTask" /* Actions Runner polling */ { + routineControlPlaneRequest := false + switch req.RequestURI { + case "/api/actions/runner.v1.RunnerService/FetchTask", + "/api/codespace/codespace.v1.ManagerService/DeclareManager", + "/api/codespace/codespace.v1.ManagerService/FetchOperations", + "/api/codespace/codespace.v1.ManagerService/ReportInstances", + "/api/codespace/codespace.v1.ManagerService/UpdateLog", + "/api/codespace/codespace.v1.ManagerService/ReportRuntimeMetadata", + "/api/codespace/codespace.v1.ManagerService/ValidatePublicEndpoint", + "/api/codespace/codespace.v1.ManagerService/RevalidateGatewaySession": + routineControlPlaneRequest = true + } + // Successful polling and client-cancelled polling do not carry useful information at the default level. + if routineControlPlaneRequest && ((status > 0 && status < 400) || (status == 0 && req.Context().Err() != nil)) { logLevel = log.TRACE } message := completedMessage diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index b66f4b3dc0775..84844ffa5581f 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -140,6 +140,7 @@ "confirm_delete_selected": "Confirm to delete all selected items?", "confirm_remove": "Confirm to remove?", "name": "Name", + "status": "Status", "value": "Value", "readme": "Readme", "filter_title": "Filter", @@ -3002,6 +3003,262 @@ "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_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. 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", + "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 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.", + "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.pending": "Never connected", + "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. 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", + "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_dev_container_templates": "No Dev Container templates.", + "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.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", + "codespace.repository_unavailable": "Repository unavailable", + "codespace.requested": "Requested", + "codespace.granted": "Granted", + "codespace.reduce_to_read": "Reduce to read", + "codespace.revoke": "Revoke", + "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_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.cleanup_action_runs": "Delete action runs older than retention period", "admin.dashboard.server_uptime": "Server Uptime", 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..b198102fd351c --- /dev/null +++ b/routers/api/codespace/manager/interceptor.go @@ -0,0 +1,125 @@ +// 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" + 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) { + 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 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 { + 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..3d463c400237d --- /dev/null +++ b/routers/api/codespace/manager/manager.go @@ -0,0 +1,397 @@ +// 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" + 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(codespace_service.ManagerServiceMaxMessageSize), + connect.WithSendMaxBytes(codespace_service.ManagerServiceMaxMessageSize), + withManager, + ) +} + +var _ codespacev1connect.ManagerServiceHandler = (*Service)(nil) + +// Service implements the Codespace ManagerService RPC entrypoint. +type Service struct { + codespacev1connect.UnimplementedManagerServiceHandler +} + +// CheckManager confirms the authenticated Manager identity without changing it. +func (s *Service) CheckManager( + ctx context.Context, + _ *connect.Request[codespacev1.CheckManagerRequest], +) (*connect.Response[codespacev1.CheckManagerResponse], error) { + manager := GetManager(ctx) + _, _, _, giteaWebURL := codespace_service.ManagerServiceTimings() + return connect.NewResponse(&codespacev1.CheckManagerResponse{ + GiteaWebUrl: giteaWebURL, + ManagerName: manager.Name, + }), 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(), + RuntimeState: req.Msg.GetManagerRuntimeState(), + GatewaySSHHostKeyAlgorithm: req.Msg.GetGatewaySshHostKeyAlgorithm(), + GatewaySSHHostKeyFingerprintSHA256: req.Msg.GetGatewaySshHostKeyFingerprintSha256(), + GatewaySSHHostKeyUpdatedUnix: req.Msg.GetGatewaySshHostKeyUpdatedUnix(), + }); err != nil { + return nil, serviceFailureError(err, "invalid_declaration", nil) + } + 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, "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 +} + +// 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, + 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.GetRuntimeUuid(), + OperationRVersion: req.Msg.GetOperationRversion(), + OperationType: req.Msg.GetOperationType(), + FinalStatus: req.Msg.GetStatus(), + }) + if err != nil { + 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"}, + }) + } + 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.GetRuntimeUuid(), + 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.GetRuntimeUuid(), + 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.GetRuntimeUuid(), + 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 { + if staleGeneration, ok := errors.AsType[*codespace_service.StaleGenerationError](err); ok { + return failureErrorWithStaleGeneration(connect.CodeFailedPrecondition, "stale_generation", staleGeneration.CurrentGeneration, err) + } + return serviceFailureError(err, "invalid_argument", cases) +} + +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(connect.CodeInvalidArgument, 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.GetRuntimeUuid(), + OperationRVersion: req.Msg.GetOperationRversion(), + GitSSHPublicKey: req.Msg.GetGitSshKey().GetPublicKey(), + }) + if err != nil { + 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"}, + {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{ + Access: &codespacev1.RuntimeAccessBundle{ + GiteaToken: result.Token, + GiteaServerUrl: result.ServerURL, + Secrets: secrets, + GitSshTrust: &codespacev1.GitSSHTrust{KnownHostsLines: 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.GetRuntimeUuid(), + ObservedAutoStopEnabled: settings.GetAutoStopEnabled(), + ObservedIdleTimeoutSeconds: settings.GetIdleTimeoutSeconds(), + ObservedInteractionGeneration: settings.GetInteractionGeneration(), + }) + if err != nil { + 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"}, + {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.GetRuntimeUuid(), + 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.GetRuntimeUuid(), + 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..29f754812170e --- /dev/null +++ b/routers/api/codespace/manager/manager_test.go @@ -0,0 +1,534 @@ +// 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" + codespace_service "gitea.dev/services/codespace" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestManagerServiceProtocolAuthenticationAndDeclaration(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + client, cleanup := newManagerTestClient(t) + defer cleanup() + + 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, 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(managerID).Get(managerBeforeDeclare) + require.NoError(t, err) + require.True(t, has) + assert.Zero(t, managerBeforeDeclare.LastOnlineUnix) + assert.Equal(t, codespace_model.ManagerRuntimeStateRecovering, managerBeforeDeclare.RuntimeState) + + 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 ", + ManagerRuntimeState: codespacev1.ManagerRuntimeState_MANAGER_RUNTIME_STATE_ONLINE, + GatewaySshHostKeyAlgorithm: " ssh-ed25519 ", + GatewaySshHostKeyFingerprintSha256: " SHA256:test ", + GatewaySshHostKeyUpdatedUnix: 1, + } + _, 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(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(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(managerID, secret, 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(managerID).Get(manager) + require.NoError(t, err) + require.True(t, has) + 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) + 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.CheckManagerRequest{}, + &codespacev1.DeclareManagerRequest{}, + &codespacev1.FetchOperationsRequest{}, + &codespacev1.BindRuntimeIdentityRequest{}, + &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 TestManagerServiceDeclareAllowsSharedGatewayAddresses(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.NoError(t, err) + + _, err = client.DeclareManager(t.Context(), managerRequest(secondManager.ID, secondSecret, + managerTestDeclaration("https://other-gateway.example.com", "workspace.example.com:22"))) + 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, 2, 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.GetRuntimeUuid()) + assert.EqualValues(t, 41, operation.GetOperationRversion()) + assert.Positive(t, operation.GetLeaseValidForMilliseconds()) + require.NotNil(t, operation.GetCreate()) + require.NotNil(t, operation.GetCreate().GetRepository()) + assert.NotEmpty(t, operation.GetCreate().GetRepository().GetCloneHttpUrl()) + assert.NotEmpty(t, operation.GetCreate().GetRepository().GetCloneSshUrl()) + assert.Equal(t, codespacev1.GitProtocol_GIT_PROTOCOL_HTTP, operation.GetCreate().GetRepository().GetPreferredProtocol()) + 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{{ + 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].GetRuntimeUuid()) +} + +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, + RuntimeUuid: 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, + RuntimeUuid: 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, + RuntimeUuid: "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, + RuntimeUuid: "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, + RuntimeUuid: "93939393-9393-4939-8939-939393939393", + OperationRversion: 1, + GitSshKey: &codespacev1.RuntimeGitSSHKey{PublicKey: []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, + RuntimeUuid: codespaceUUID, + MetadataGeneration: math.MaxInt64, + Metadata: managerTestRuntimeMetadata(31), + })) + require.NoError(t, err) + + _, err = client.ReportRuntimeMetadata(t.Context(), managerRequest(manager.ID, secret, &codespacev1.ReportRuntimeMetadataRequest{ + ProtocolVersion: 1, + RuntimeUuid: 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, + RuntimeUuid: "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", + 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.DevContainerSource = codespace_model.DevContainerSourceTemplate + codespace.DevContainerContent = `{"image":"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 8953647a99aa9..9bf710f5445ea 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -66,6 +66,7 @@ import ( "errors" "fmt" "net/http" + "slices" "strings" audit_model "gitea.dev/models/audit" @@ -77,12 +78,14 @@ 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/validation" "gitea.dev/modules/web" "gitea.dev/modules/web/middleware" + 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" @@ -107,6 +110,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") @@ -115,6 +126,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 { @@ -196,6 +211,7 @@ func repoAssignment() func(ctx *context.APIContext) { repo.Owner = owner ctx.Repo.Repository = repo + ctx.UseAnonymousForPublicCodespaceRead(repo) { needTwoFactor, err := doerNeedTwoFactorAuth(ctx, ctx.Doer) @@ -220,6 +236,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 } @@ -322,6 +342,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) { @@ -461,6 +524,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 @@ -471,6 +538,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 @@ -478,6 +549,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) { @@ -922,8 +1036,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) @@ -938,6 +1059,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 { @@ -1034,14 +1158,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, ) { @@ -1069,6 +1193,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) }) @@ -1089,9 +1221,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) @@ -1172,7 +1304,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) @@ -1314,9 +1446,10 @@ func Routes() *web.Router { m.Post("/migrate", reqToken(), rejectPublicOnly(), 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(), reqRepoDangerZone(), repo.Delete). Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit) m.Post("/generate", reqToken(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate) @@ -1327,7 +1460,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) @@ -1336,12 +1473,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) @@ -1370,21 +1507,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) @@ -1392,7 +1529,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) @@ -1408,7 +1545,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) @@ -1447,7 +1584,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) @@ -1458,7 +1595,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). @@ -1467,10 +1604,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() { @@ -1499,8 +1636,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). @@ -1510,7 +1647,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) @@ -1548,11 +1685,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.Group("", func() { m.Get("", repo.GetAllCommits) @@ -1566,7 +1703,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) @@ -1578,8 +1715,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) @@ -1591,17 +1728,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(), mustNotBeArchived, bind(api.RepoTopicOptions{}), repo.UpdateTopics) @@ -1610,25 +1747,25 @@ func Routes() *web.Router { Delete(reqToken(), mustNotBeArchived, 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() { @@ -1762,7 +1899,7 @@ func Routes() *web.Router { m.Group("/projects", func() { addProjectRoutes(m, reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived) }, reqRepoReader(unit.TypeProjects), mustEnableRepoProjects) - }, 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 @@ -1808,12 +1945,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 index 8ab4a496e2a86..2696e45e31691 100644 --- a/routers/api/v1/api_test.go +++ b/routers/api/v1/api_test.go @@ -4,16 +4,131 @@ 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/modules/setting" "gitea.dev/modules/test" + "gitea.dev/routers/common" + "gitea.dev/services/contexttest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +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 +} + func TestDoerNeedTwoFactorAuth(t *testing.T) { defer test.MockVariableValue(&setting.TwoFactorAuthEnforced, true)() diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index 83bb07aabca95..95cca1965cd85 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" @@ -933,6 +934,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) @@ -1015,6 +1020,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) @@ -1091,6 +1102,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 { @@ -1125,10 +1140,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) @@ -1166,6 +1185,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 @@ -1240,10 +1318,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 76e8fb9d6421f..ff2fcf23d7d68 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 7770243e4347a..cbd01f34d38c0 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" gitproxy_service "gitea.dev/services/gitproxy" @@ -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 f900361819a8d..74641de31c98a 100644 --- a/routers/private/serv.go +++ b/routers/private/serv.go @@ -4,6 +4,8 @@ package private import ( + "errors" + "fmt" "net/http" "strings" @@ -20,6 +22,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" @@ -45,7 +48,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) { @@ -60,6 +64,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) } @@ -187,13 +197,17 @@ func ServCommand(ctx *context.PrivateContext) { } results.PublicKeyID = key.ID + // 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 + } + var deployKey *deploykey_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 = deploykey_model.GetDeployKeyByRepoPublicKey(ctx, repo.ID, key.ID) if err != nil { if deploykey_model.IsErrDeployKeyNotExist(err) { @@ -204,7 +218,7 @@ func ServCommand(ctx *context.PrivateContext) { return } user = user_model.NewDeployKeyUserWithKeyID(deployKey.ID) - } else { + case asymkey_model.KeyTypeUser, asymkey_model.KeyTypePrincipal: // Get the user represented by the Key user, err = user_model.GetUserByID(ctx, key.OwnerID) if err != nil { @@ -219,6 +233,17 @@ func ServCommand(ctx *context.PrivateContext) { ctx.PrivateUserErrorf(http.StatusForbidden, "Your account is disabled.") return } + 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 + } + default: + ctx.PrivateInternalErrorf("Unsupported public key type %d for key: %d", key.Type, key.ID) + return } results.UserID = user.ID @@ -306,3 +331,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..5466c0f151367 --- /dev/null +++ b/routers/web/codespace/action.go @@ -0,0 +1,260 @@ +// 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 + } + 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) + 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 + } + 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) + 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 + } + 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) + 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 + } + 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, + CodespaceID: codespaceID, + }) + 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 + } + 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 { + case codespace_model.AutoStopModeDefault, codespace_model.AutoStopModeNever: + case codespace_model.AutoStopModeCustom: + var ok bool + timeout, ok = parseAutoStopTimeoutForm(ctx) + if !ok { + ctx.JSONErrorWithField(ctx.Tr("codespace.auto_stop_invalid_duration"), "timeout_value") + return + } + default: + ctx.JSONError(ctx.Tr("codespace.error.invalid_request")) + return + } + _, err := codespace_service.UpdateAutoStop(ctx, codespace_service.UpdateAutoStopOptions{ + UserID: ctx.Doer.ID, + CodespaceID: codespaceID, + Mode: mode, + CustomTimeoutSeconds: timeout, + }) + if err != nil { + if errors.Is(err, codespace_service.ErrInteractionInvalidArgument) { + ctx.JSONErrorWithField(ctx.Tr("codespace.auto_stop_invalid_range"), "timeout_value") + return + } + 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.JSONRedirect(returnPath) +} + +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 { + codespaceID, _ := codespaceIDParam(ctx) + return codespace_service.LifecycleActionOptions{ + 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(codespaceID int64) string { + return "/-/codespaces/" + strconv.FormatInt(codespaceID, 10) +} + +func codespaceActionReturnPath(codespaceID int64, 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(codespaceID) + 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..4d408f3ff59fd --- /dev/null +++ b/routers/web/codespace/action_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "net/http" + "net/url" + "testing" + + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +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 + want string + }{ + {"", "/-/codespaces/12"}, + {"/-/codespaces?state=deleted", "/-/codespaces?state=deleted"}, + {"/-/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(12, test.input, codespaceDetailPath(12))) + } + 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/12/action") + ctx.SetPathParam("codespace_id", "12") + if test.interaction { + handleInteractionError(ctx, "TestAction", test.err, "/-/codespaces/12") + } else { + handleLifecycleActionError(ctx, "TestAction", test.err, "/-/codespaces/12") + } + assert.Equal(t, test.status, resp.Code) + if test.redirect { + assert.Equal(t, "/-/codespaces/12", resp.Header().Get("Location")) + } + }) + } +} diff --git a/routers/web/codespace/create.go b/routers/web/codespace/create.go new file mode 100644 index 0000000000000..afeae77911de0 --- /dev/null +++ b/routers/web/codespace/create.go @@ -0,0 +1,190 @@ +// 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 +} + +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 { + 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 { + 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 + } + 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 { + 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 + } + 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.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 + } + 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 + } + renderCreateConfirm(ctx, http.StatusUnprocessableEntity, plan, opts, ctx.Tr("codespace.error.invalid_create_request")) + return + } + 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) { + 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 { + 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 + } + } + hasPendingRecommendedSecret := false + for _, secret := range plan.RecommendedSecrets { + 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"] = 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) +} + +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..e1943c0a724e1 --- /dev/null +++ b/routers/web/codespace/log.go @@ -0,0 +1,143 @@ +// 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 + } + codespaceID, ok := codespaceIDParam(ctx) + if !ok { + 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, + CodespaceID: codespaceID, + Offset: offset, + Limit: limit, + }) + if err != nil { + if offsetErr, ok := errors.AsType[*codespace_service.LogOffsetError](err); ok { + 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 + } + codespaceID, ok := codespaceIDParam(ctx) + if !ok { + return + } + result, err := codespace_service.ReadLog(ctx, codespace_service.ReadLogOptions{ + UserID: ctx.Doer.ID, + CodespaceID: codespaceID, + 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("codespace-"+strconv.FormatInt(codespaceID, 10)+".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, + CodespaceID: codespaceID, + Offset: result.NextOffset, + Limit: codespace_service.LogReadMaxBytes, + }) + if err != nil { + log.Error("Read Codespace log %d after response started: %v", codespaceID, 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..d2fc4691713fc --- /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/session" + "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/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs?offset=0&limit=40") + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) + 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()) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/1/logs?offset=bad") + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("codespace_id", "1") + 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/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs") + contexttest.LoadUser(t, ctx, 2) + 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/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs/download") + contexttest.LoadUser(t, ctx, 2) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) + 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/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs?offset="+strconv.FormatInt(result.NextOffset+1, 10)) + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) + 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/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10), contexttest.MockContextOption{Render: templates.PageRenderer(), SessionStore: session.NewMockMemStore("codespace-log")}) + contexttest.LoadUser(t, detailCtx, 1) + 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/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs/download") + + downloadCtx, downloadResp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs/download") + contexttest.LoadUser(t, downloadCtx, 1) + 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"), "codespace-"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+".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/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/logs/download") + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) + 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..03da24d75b801 --- /dev/null +++ b/routers/web/codespace/open.go @@ -0,0 +1,59 @@ +// 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 + } + codespaceID, ok := codespaceIDParam(ctx) + if !ok { + return + } + result, err := codespace_service.OpenEndpoint(ctx, codespace_service.OpenEndpointOptions{ + UserID: ctx.Doer.ID, + CodespaceID: codespaceID, + 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(codespaceID), 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..926b80240d138 --- /dev/null +++ b/routers/web/codespace/open_test.go @@ -0,0 +1,166 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "net/http" + "net/url" + "slices" + "strconv" + "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, []*codespacev1.RuntimeEndpoint{{ + EndpointId: "app-3000", + Label: "App", + }}), + MetadataGeneration: 1, + })) + + ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/open/app-3000") + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) + 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, []*codespacev1.RuntimeEndpoint{{ + EndpointId: "app-3000", + Label: "App", + Public: true, + }}), + MetadataGeneration: 1, + })) + + ctx, resp := contexttest.MockContext(t, "POST /-/codespaces/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/open/app-3000") + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) + 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/"+strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)+"/open") + contexttest.LoadUser(t, ctx, 2) + ctx.SetPathParam("codespace_id", strconv.FormatInt(webCodespaceIDByUUID(t, codespaceUUID), 10)) + 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 []*codespacev1.RuntimeEndpoint) *codespacev1.RuntimeMetadata { + t.Helper() + metadataEndpoints := slices.Clone(endpoints) + 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..36fe7d0701928 --- /dev/null +++ b/routers/web/codespace/settings.go @@ -0,0 +1,394 @@ +// 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" + tplAdminDevContainerTemplates templates.TplName = "codespace/admin_devcontainer_templates" + tplUserCodespaceSettings templates.TplName = "codespace/user_settings" + tplUserCodespaceManagerDetail templates.TplName = "codespace/user_manager_detail" + tplUserDevContainerTemplates templates.TplName = "codespace/user_devcontainer_templates" +) + +// AdminManagers renders site-wide Manager 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", + }) +} + +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", + }) +} + +func AdminDevContainerTemplates(ctx *context.Context) { + renderDevContainerTemplateSettings(ctx, devContainerTemplateRenderOptions{ + UserID: 0, + ActionBase: setting.AppSubURL + "/-/admin/codespaces/dev-container-templates", + Template: tplAdminDevContainerTemplates, + PageFlag: "PageIsAdminCodespaceDevContainerTemplates", + }) +} + +func AdminDevContainerTemplatePost(ctx *context.Context) { + handleDevContainerTemplateUpsert(ctx, devContainerTemplateRenderOptions{ + UserID: 0, + ActionBase: setting.AppSubURL + "/-/admin/codespaces/dev-container-templates", + }, 0) +} + +func AdminDevContainerTemplateUpdate(ctx *context.Context) { + handleDevContainerTemplateUpsert(ctx, devContainerTemplateRenderOptions{ + UserID: 0, + ActionBase: setting.AppSubURL + "/-/admin/codespaces/dev-container-templates", + }, ctx.PathParamInt64("template_id")) +} + +func AdminDevContainerTemplateDelete(ctx *context.Context) { + handleDevContainerTemplateDelete(ctx, devContainerTemplateRenderOptions{ + UserID: 0, + ActionBase: setting.AppSubURL + "/-/admin/codespaces/dev-container-templates", + }) +} + +// UserSettings renders current user's Manager 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", + }) +} + +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", + }) +} + +func UserDevContainerTemplates(ctx *context.Context) { + renderDevContainerTemplateSettings(ctx, devContainerTemplateRenderOptions{ + UserID: ctx.Doer.ID, + ActionBase: setting.AppSubURL + "/user/settings/codespaces/dev-container-templates", + Template: tplUserDevContainerTemplates, + PageFlag: "PageIsCodespaceDevContainerTemplates", + }) +} + +func UserDevContainerTemplatePost(ctx *context.Context) { + handleDevContainerTemplateUpsert(ctx, devContainerTemplateRenderOptions{ + UserID: ctx.Doer.ID, + ActionBase: setting.AppSubURL + "/user/settings/codespaces/dev-container-templates", + }, 0) +} + +func UserDevContainerTemplateUpdate(ctx *context.Context) { + handleDevContainerTemplateUpsert(ctx, devContainerTemplateRenderOptions{ + UserID: ctx.Doer.ID, + ActionBase: setting.AppSubURL + "/user/settings/codespaces/dev-container-templates", + }, ctx.PathParamInt64("template_id")) +} + +func UserDevContainerTemplateDelete(ctx *context.Context) { + handleDevContainerTemplateDelete(ctx, devContainerTemplateRenderOptions{ + UserID: ctx.Doer.ID, + ActionBase: setting.AppSubURL + "/user/settings/codespaces/dev-container-templates", + }) +} + +type managerSettingsRenderOptions struct { + Scope string + UserID int64 + ActionBase string + Template templates.TplName + PageFlag string +} + +type devContainerTemplateRenderOptions struct { + UserID int64 + ActionBase string + Template templates.TplName + PageFlag string +} + +func renderDevContainerTemplateSettings(ctx *context.Context, opts devContainerTemplateRenderOptions) { + templates, err := codespace_service.ListDevContainerTemplates(ctx, opts.UserID) + if err != nil { + ctx.ServerError("ListDevContainerTemplates", err) + return + } + 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) +} + +func handleDevContainerTemplateUpsert(ctx *context.Context, opts devContainerTemplateRenderOptions, templateID int64) { + err := codespace_service.UpsertDevContainerTemplate(ctx, codespace_service.DevContainerTemplateUpsertOptions{ + UserID: opts.UserID, + ID: templateID, + Name: ctx.FormString("name"), + Content: ctx.FormString("content"), + }) + if err != nil { + handleDevContainerTemplateActionError(ctx, err) + return + } + ctx.Flash.Success(ctx.Tr("settings.saved_successfully")) + ctx.JSONRedirect(opts.ActionBase) +} + +func handleDevContainerTemplateDelete(ctx *context.Context, opts devContainerTemplateRenderOptions) { + err := codespace_service.DeleteDevContainerTemplate(ctx, codespace_service.DevContainerTemplateDeleteOptions{ + UserID: opts.UserID, + ID: ctx.PathParamInt64("template_id"), + }) + if err != nil { + handleDevContainerTemplateActionError(ctx, err) + return + } + ctx.JSONRedirect(opts.ActionBase) +} + +func handleDevContainerTemplateActionError(ctx *context.Context, err error) { + switch { + case errors.Is(err, codespace_service.ErrDevContainerTemplateNotFound): + 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.JSONErrorAuto(err) + } +} + +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 false + } + 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 false + } + 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.NewPagerBuilder(ctx).TotalCount(unassigned.Total).PerPageLimit(setting.UI.Admin.UserPagingNum).CurPage(page).Build() + } + return true +} + +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.NewPagerBuilder(ctx).TotalCount(detail.Total).PerPageLimit(setting.UI.Admin.UserPagingNum).CurPage(page).Build() + 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 handleManagerSettingsCreateManager(ctx *context.Context, opts managerSettingsRenderOptions) { + managerID, 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 { + handleManagerEditError(ctx, err) + return + } + 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.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) { + 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..34d6fc89cc989 --- /dev/null +++ b/routers/web/codespace/settings_permission.go @@ -0,0 +1,66 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "errors" + "net/http" + + "gitea.dev/models/perm" + "gitea.dev/models/unit" + "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("target_repo_id"), unit.Type(ctx.FormInt("unit_type")), 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..5e93200edc894 --- /dev/null +++ b/routers/web/codespace/view.go @@ -0,0 +1,210 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +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" + "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) + 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) +} + +// 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) { + 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 { + tab = view.DetailMode + } + 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) { + if ctx.Doer == nil { + 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, + CodespaceID: codespaceID, + }) + 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..37c874decf0f0 --- /dev/null +++ b/routers/web/codespace/view_test.go @@ -0,0 +1,196 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "net/http" + "strconv" + "testing" + + 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/session" + "gitea.dev/modules/templates" + codespace_service "gitea.dev/services/codespace" + "gitea.dev/services/context" + "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(), SessionStore: session.NewMockMemStore("codespace-list")}) + 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(), "codespace-row-"+strconv.FormatInt(rows[0].ID, 10)) + 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()).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&partial=true", contexttest.MockContextOption{Render: templates.PageRenderer(), SessionStore: session.NewMockMemStore("codespace-org-list")}) + 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"]) + 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) { + 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, + }) + codespaceID := webCodespaceIDByUUID(t, codespaceUUID) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(codespaceID, 10), contexttest.MockContextOption{Render: templates.PageRenderer(), SessionStore: session.NewMockMemStore("codespace-detail")}) + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("codespace_id", strconv.FormatInt(codespaceID, 10)) + 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.Equal(t, codespace_service.DetailModeLogs, ctx.Data["CodespaceTab"]) + explicit, ok := ctx.Data["CodespaceTabExplicit"].(bool) + require.True(t, ok) + assert.False(t, explicit) +} + +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, + }) + codespaceID := webCodespaceIDByUUID(t, codespaceUUID) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(codespaceID, 10)+"?tab=overview", contexttest.MockContextOption{Render: templates.PageRenderer(), SessionStore: session.NewMockMemStore("codespace-overview")}) + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("codespace_id", strconv.FormatInt(codespaceID, 10)) + Detail(ctx) + + require.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, codespace_service.DetailModeOverview, ctx.Data["CodespaceTab"]) + 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\"") +} + +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) + codespaceID := webCodespaceIDByUUID(t, codespaceUUID) + require.NoError(t, codespace_service.ReportRuntimeMetadata(t.Context(), manager, codespace_service.ReportRuntimeMetadataOptions{ + CodespaceUUID: codespaceUUID, + Metadata: webOpenRuntimeMetadata(t, 94, []*codespacev1.RuntimeEndpoint{{ + EndpointId: "app-3000", + Label: "App", + }}), + MetadataGeneration: 1, + })) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(codespaceID, 10)+"?open_endpoint=app-3000", contexttest.MockContextOption{Render: templates.PageRenderer(), SessionStore: session.NewMockMemStore("codespace-open")}) + contexttest.LoadUser(t, ctx, 1) + ctx.SetPathParam("codespace_id", strconv.FormatInt(codespaceID, 10)) + 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/"+strconv.FormatInt(codespaceID, 10)+"/open/app-3000", modal.OpenPath) +} + +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, + }) + codespaceID := webCodespaceIDByUUID(t, codespaceUUID) + + ctx, resp := contexttest.MockContext(t, "GET /-/codespaces/"+strconv.FormatInt(codespaceID, 10)) + contexttest.LoadUser(t, ctx, 2) + ctx.SetPathParam("codespace_id", strconv.FormatInt(codespaceID, 10)) + 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)) +} + +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/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/actions/badge.go b/routers/web/repo/actions/badge.go index 01a78a7f190ae..6507a10791015 100644 --- a/routers/web/repo/actions/badge.go +++ b/routers/web/repo/actions/badge.go @@ -11,6 +11,7 @@ import ( actions_model "gitea.dev/models/actions" auth_model "gitea.dev/models/auth" + "gitea.dev/models/unit" "gitea.dev/modules/badge" "gitea.dev/modules/git" "gitea.dev/modules/util" @@ -18,7 +19,7 @@ import ( ) func GetWorkflowBadge(ctx *context.Context) { - context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read) + context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, unit.TypeActions, auth_model.Read) if ctx.Written() { return } diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 7ce02765905dc..d7d8b9315520b 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 da2bf49557ec3..89c72913dcfb9 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -159,7 +159,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 06d55f938e28f..622aed457ce7c 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -420,6 +420,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 e99667a0d1437..06bd400bd246c 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -648,6 +648,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 { @@ -702,6 +705,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 e829520ce6274..2c9315c1966b7 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) { @@ -417,7 +476,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 addb9caec9a33..efeadbede6147 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -635,7 +635,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) @@ -646,9 +647,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 98d88eb154f5a..51b5366f282b6 100644 --- a/routers/web/user/setting/keys.go +++ b/routers/web/user/setting/keys.go @@ -317,8 +317,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 0aecc64c4767c..26c583a83f6a1 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" @@ -94,36 +95,41 @@ func optionsCorsHandler() func(next http.Handler) http.Handler { } type AuthMiddleware struct { - AllowOAuth2 types.PreMiddlewareProvider - AllowBasic types.PreMiddlewareProvider - AllowDeployToken types.PreMiddlewareProvider - MiddlewareHandler func(*context.Context) + AllowOAuth2 types.PreMiddlewareProvider + AllowBasic types.PreMiddlewareProvider + AllowCodespaceToken types.PreMiddlewareProvider + AllowDeployToken types.PreMiddlewareProvider + MiddlewareHandler func(*context.Context) } func newWebAuthMiddleware() *AuthMiddleware { type keyAllowOAuth2 struct{} type keyAllowBasic struct{} + type keyAllowCodespaceToken struct{} type keyAllowDeployToken struct{} webAuth := &AuthMiddleware{} - middlewareSetContextValue := func(key, val any) types.PreMiddlewareProvider { + middlewareSetContextValue := func(key any) types.PreMiddlewareProvider { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dataStore := reqctx.GetRequestDataStore(r.Context()) - dataStore.SetContextValue(key, val) + dataStore.SetContextValue(key, true) next.ServeHTTP(w, r) }) } } - webAuth.AllowBasic = middlewareSetContextValue(keyAllowBasic{}, true) - webAuth.AllowOAuth2 = middlewareSetContextValue(keyAllowOAuth2{}, true) - webAuth.AllowDeployToken = middlewareSetContextValue(keyAllowDeployToken{}, true) + webAuth.AllowBasic = middlewareSetContextValue(keyAllowBasic{}) + webAuth.AllowOAuth2 = middlewareSetContextValue(keyAllowOAuth2{}) + webAuth.AllowCodespaceToken = middlewareSetContextValue(keyAllowCodespaceToken{}) + webAuth.AllowDeployToken = middlewareSetContextValue(keyAllowDeployToken{}) 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) allowDeployToken := ctx.GetContextValue(keyAllowDeployToken{}) == true group := auth_service.NewGroup() @@ -139,6 +145,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 @@ -159,6 +170,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 @@ -552,6 +567,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", crossOriginProtect, misc.WebThemeApply) + m.Get("/-/codespaces", reqSignIn, web_codespace.List) + m.Group("/-/codespaces/{codespace_id}", 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) { @@ -736,6 +765,21 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { addSettingsScopedWorkflowsRoutes() }, actions.MustEnableActions) + 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) + m.Post("/codespaces/dev-container-templates/{template_id}/delete", web_codespace.UserDevContainerTemplateDelete) + 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) @@ -857,6 +901,24 @@ 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/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) + m.Post("/dev-container-templates/{template_id}/delete", web_codespace.AdminDevContainerTemplateDelete) + 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) @@ -1293,6 +1355,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) @@ -1773,12 +1838,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, webAuth.AllowDeployToken, repo.CorsHandler(), optSignInFromAnyOrigin) + common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, webAuth.AllowBasic, webAuth.AllowCodespaceToken, webAuth.AllowDeployToken, 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, webAuth.AllowDeployToken, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb()) + addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, webAuth.AllowCodespaceToken, webAuth.AllowDeployToken, 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 645c4a9c7ffd4..93c82ddd7acd8 100644 --- a/services/asymkey/commit.go +++ b/services/asymkey/commit.go @@ -382,8 +382,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 e95aa3439a8bb..9204bcbf9aec9 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 8670a027eb9f5..507ec826efb84 100644 --- a/services/asymkey/ssh_key.go +++ b/services/asymkey/ssh_key.go @@ -28,6 +28,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 feec2e57b51e2..24b230cf0f987 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -18,6 +18,7 @@ import ( "gitea.dev/modules/timeutil" "gitea.dev/modules/util" "gitea.dev/services/audit" + codespace_service "gitea.dev/services/codespace" ) // Ensure the struct implements the interface. @@ -72,6 +73,19 @@ func parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd str // 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, grantID := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken) if uid != 0 { @@ -203,6 +217,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..2ac57bbbc8f14 --- /dev/null +++ b/services/auth/codespace.go @@ -0,0 +1,121 @@ +// 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 { + // Once a credential uses the Codespace prefix, reject it terminally so another auth method cannot reinterpret it with broader authority. + 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..d8741b1fb3345 --- /dev/null +++ b/services/auth/codespace_test.go @@ -0,0 +1,288 @@ +// 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, ok := store.GetData()["ApiTokenScope"].(auth_model.AccessTokenScope) + require.True(t, ok) + assertContainsCodespaceScopes(t, scope) + snapshot, ok := store.GetData()[codespace_model.GiteaTokenAuthDataKey].(*codespace_service.GiteaTokenAuthSnapshot) + require.True(t, ok) + 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"]) + scope, ok := store.GetData()["ApiTokenScope"].(auth_model.AccessTokenScope) + require.True(t, ok) + assertContainsCodespaceScopes(t, scope) +} + +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(t)) + 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(t)) + 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(t)) + 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(t)) + 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()). + Where("uuid = ?", 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 2fa3ba2a34065..648a5dec868b2 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" ) @@ -86,11 +87,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) { // userFromToken returns the user corresponding to the OAuth token. // It will 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..005407bdc7ad3 --- /dev/null +++ b/services/codespace/configuration.go @@ -0,0 +1,153 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/modules/setting" +) + +const ( + // ManagerServiceMaxMessageSize is the fixed request and response limit for the Manager control plane. + ManagerServiceMaxMessageSize = 32 * 1024 * 1024 + openTokenExpire = 60 * time.Second + runtimeMetadataMaxSize = 256 * 1024 + devContainerConfigMaxSize = 64 * 1024 +) + +// 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.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.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") + } + 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), + ManagerServiceMaxMessageSize, + setting.AppURL +} diff --git a/services/codespace/create.go b/services/codespace/create.go new file mode 100644 index 0000000000000..d8c1b21311f72 --- /dev/null +++ b/services/codespace/create.go @@ -0,0 +1,765 @@ +// 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 { + CodespaceID int64 + 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. +// 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 && !errors.Is(err, ErrCreateConfigurationInvalid) { + return nil, err + } + configurationErr := 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, + }, configurationErr +} + +// 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 + // Rebuild the reviewed plan under both locks so ref, configuration, permissions, and secrets cannot change before the transaction commits. + 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{ + CodespaceID: codespace.ID, + 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 { + 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. + 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 !git.IsStringValidObjectID(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 { + 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("%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("%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) + if err != nil { + return nil, err + } + if !allowed { + 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, + 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 { + // The environment tag is a final form choice revalidated by CreateCodespace; the hash covers repository-derived inputs that require renewed review. + 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.Source, devContainer.Path, devContainer.Content) + 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() + codespace := &codespace_model.Codespace{ + UserID: userID, + RepoID: repoID, + RefType: sourceRef.Type, + RefName: sourceRef.StoredName, + EnvironmentTag: environmentTag, + CommitSHA: sourceRef.CommitSHA, + DevContainerSource: devContainer.Source, + DevContainerPath: devContainer.Path, + DevContainerContent: devContainer.Content, + 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..3e8b6e6d0307d --- /dev/null +++ b/services/codespace/create_test.go @@ -0,0 +1,472 @@ +// 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" + 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()) + + insertServiceDefaultDevContainerTemplate(t) + 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 := loadServiceCodespaceByID(t, result.CodespaceID) + 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, codespace_model.DevContainerSourceTemplate, row.DevContainerSource) + assert.Empty(t, row.DevContainerPath) + assert.JSONEq(t, `{"image":"mcr.microsoft.com/devcontainers/base:ubuntu"}`, row.DevContainerContent) + 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()) + + insertServiceDefaultDevContainerTemplate(t) + 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 := loadServiceCodespaceByID(t, result.CodespaceID) + assert.Equal(t, user.ID, row.UserID) + assert.Equal(t, repo.ID, row.RepoID) +} + +func TestCreateCodespaceRequiresAvailableEnvironment(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + insertServiceDefaultDevContainerTemplate(t) + 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()) + + insertServiceDefaultDevContainerTemplate(t) + 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, + }) + + insertServiceDefaultDevContainerTemplate(t) + 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 := loadServiceCodespaceByID(t, result.CodespaceID) + 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 +} + +func insertServiceDevContainerTemplate(t *testing.T, userID int64, name, content string) *codespace_model.DevContainerTemplate { + t.Helper() + template := &codespace_model.DevContainerTemplate{ + UserID: userID, + Name: name, + Content: content, + CreatedUnix: time.Now().Unix(), + UpdatedUnix: time.Now().Unix(), + } + require.NoError(t, db.Insert(t.Context(), template)) + return template +} + +func insertServiceDefaultDevContainerTemplate(t *testing.T) *codespace_model.DevContainerTemplate { + t.Helper() + return insertServiceDevContainerTemplate(t, 0, "Default", `{"image":"mcr.microsoft.com/devcontainers/base:ubuntu"}`) +} diff --git a/services/codespace/devcontainer.go b/services/codespace/devcontainer.go new file mode 100644 index 0000000000000..1a43dc62c9e89 --- /dev/null +++ b/services/codespace/devcontainer.go @@ -0,0 +1,423 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "path" + "slices" + "sort" + "strconv" + "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/util" + + "github.com/tailscale/hujson" +) + +const ( + devContainerTemplateSelectionPrefix = "template:" + 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 + Scope string + Selected bool +} + +// ErrCreateConfigurationInvalid allows the confirmation page to offer another configuration. +var ErrCreateConfigurationInvalid = errors.New("selected Dev Container configuration is invalid") + +// createDevContainerPlan contains the immutable runtime choice and confirmation data. +type createDevContainerPlan struct { + Source string + Selection string + Path string + Content string + PermissionRepositories map[string]map[string]string + Permissions []CreatePermissionRequest + RecommendedSecrets []CreateRecommendedSecret +} + +type devContainerDocument struct { + Name string `json:"name"` + Image string `json:"image"` + Build any `json:"build"` + DockerFile string `json:"dockerFile"` + DockerCompose any `json:"dockerComposeFile"` + Features map[string]any `json:"features"` + Mounts []devContainerMount `json:"mounts"` + Secrets map[string]devContainerSecret `json:"secrets"` + Customizations devContainerCustomizations `json:"customizations"` +} + +type devContainerMount struct { + Type string `json:"type"` + Source string `json:"source"` +} + +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 + } + + templates, err := listVisibleDevContainerTemplates(ctx, user.ID) + if err != nil { + return nil, nil, err + } + selection = strings.TrimSpace(selection) + if selection == "" { + if slices.Contains(paths, devContainerPrimaryPath) { + selection = devContainerPrimaryPath + } else if slices.Contains(paths, devContainerRootPath) { + selection = devContainerRootPath + } 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 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, err = loadTemplateDevContainer(templates[index]) + if err != nil { + err = errors.Join(ErrCreateConfigurationInvalid, err) + } + } else { + err = ErrCreateConfigurationInvalid + } + if err != nil { + return nil, options, err + } + permissions, err := resolveCreatePermissions(ctx, user, repo, selected.PermissionRepositories) + if err != nil { + return nil, options, err + } + selected.Permissions = permissions + 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} { + _, err := commit.GetTreeEntryByPath(ctx, gitRepo, configPath) + if err == nil { + 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") + _, err := commit.GetTreeEntryByPath(ctx, gitRepo, configPath) + if err != nil { + if git.IsErrNotExist(err) || errors.Is(err, util.ErrNotExist) { + continue + } + return nil, err + } + 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) { + 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("%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("%w: configuration %q exceeds %d bytes", ErrCreateConfigurationInvalid, configPath, devContainerConfigMaxSize) + } + document, err := parseDevContainerDocument(content, configPath) + if err != nil { + return nil, errors.Join(ErrCreateConfigurationInvalid, err) + } + repositories, err := devContainerPermissionRepositories(document.Customizations) + if err != nil { + 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("%w: parse Dev Container configuration %q: %w", ErrCreateConfigurationInvalid, configPath, err) + } + return &createDevContainerPlan{ + Source: codespace_model.DevContainerSourceRepository, + Selection: configPath, + Path: configPath, + PermissionRepositories: repositories, + RecommendedSecrets: recommendedSecrets, + }, nil +} + +func loadTemplateDevContainer(template *codespace_model.DevContainerTemplate) (*createDevContainerPlan, error) { + content := strings.TrimSpace(template.Content) + document, err := parseDevContainerDocument([]byte(content), template.Name) + if err != nil { + return nil, err + } + if err := validateTemplateDevContainer(document); err != nil { + return nil, fmt.Errorf("parse Dev Container template %q: %w", template.Name, err) + } + repositories, err := devContainerPermissionRepositories(document.Customizations) + if err != nil { + return nil, fmt.Errorf("parse Dev Container template %q: %w", template.Name, err) + } + recommendedSecrets, err := parseRecommendedSecrets(document.Secrets) + if err != nil { + return nil, fmt.Errorf("parse Dev Container template %q: %w", template.Name, err) + } + return &createDevContainerPlan{ + Source: codespace_model.DevContainerSourceTemplate, + Selection: devContainerTemplateSelectionPrefix + strconv.FormatInt(template.ID, 10), + Content: content, + PermissionRepositories: repositories, + RecommendedSecrets: recommendedSecrets, + }, nil +} + +func parseDevContainerDocument(content []byte, name string) (*devContainerDocument, error) { + if int64(len(content)) > devContainerConfigMaxSize { + return nil, fmt.Errorf("Dev Container configuration %q exceeds %d bytes", name, devContainerConfigMaxSize) + } + standard, err := hujson.Standardize(content) + if err != nil { + return nil, fmt.Errorf("parse Dev Container configuration %q: %w", name, err) + } + var document devContainerDocument + if err := json.Unmarshal(standard, &document); err != nil { + return nil, fmt.Errorf("parse Dev Container configuration %q: %w", name, err) + } + return &document, nil +} + +func validateTemplateDevContainer(document *devContainerDocument) error { + if strings.TrimSpace(document.Image) == "" || document.Build != nil || strings.TrimSpace(document.DockerFile) != "" || document.DockerCompose != nil { + return errors.New("template must select an image") + } + for reference := range document.Features { + if strings.HasPrefix(reference, "./") || strings.HasPrefix(reference, "../") { + return errors.New("template cannot use relative Features") + } + } + for _, mount := range document.Mounts { + source := strings.TrimSpace(mount.Source) + if source == "" || strings.Contains(source, "${") { + continue + } + if mount.Type == "bind" && !path.IsAbs(source) { + return errors.New("template cannot use relative bind mounts") + } + } + return 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/devcontainer_template.go b/services/codespace/devcontainer_template.go new file mode 100644 index 0000000000000..c972e7aef02cf --- /dev/null +++ b/services/codespace/devcontainer_template.go @@ -0,0 +1,96 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "strings" + "time" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" +) + +var ( + ErrDevContainerTemplateNotFound = errors.New("codespace Dev Container template not found") + ErrDevContainerTemplateNameInvalid = errors.New("invalid Dev Container template name") +) + +type DevContainerTemplateUpsertOptions struct { + UserID int64 + ID int64 + Name string + Content string +} + +type DevContainerTemplateDeleteOptions struct { + UserID int64 + ID int64 +} + +func listVisibleDevContainerTemplates(ctx context.Context, userID int64) ([]*codespace_model.DevContainerTemplate, error) { + var templates []*codespace_model.DevContainerTemplate + if err := db.GetEngine(ctx). + In("user_id", []int64{0, userID}). + Asc("user_id", "name", "id"). + Find(&templates); err != nil { + return nil, err + } + return templates, nil +} + +func ListDevContainerTemplates(ctx context.Context, userID int64) ([]*codespace_model.DevContainerTemplate, error) { + var templates []*codespace_model.DevContainerTemplate + if err := db.GetEngine(ctx).Where("user_id = ?", userID).Asc("name", "id").Find(&templates); err != nil { + return nil, err + } + return templates, nil +} + +func UpsertDevContainerTemplate(ctx context.Context, opts DevContainerTemplateUpsertOptions) error { + name := strings.TrimSpace(opts.Name) + content := strings.TrimSpace(opts.Content) + if name == "" || len(name) > 255 { + return ErrDevContainerTemplateNameInvalid + } + template := &codespace_model.DevContainerTemplate{Name: name, Content: content} + if _, err := loadTemplateDevContainer(template); err != nil { + return errors.Join(ErrCreateConfigurationInvalid, err) + } + now := time.Now().Unix() + return db.WithTx(ctx, func(ctx context.Context) error { + if opts.ID > 0 { + existing := new(codespace_model.DevContainerTemplate) + has, err := db.GetEngine(ctx).Where("id = ? AND user_id = ?", opts.ID, opts.UserID).Get(existing) + if err != nil { + return err + } + if !has { + return ErrDevContainerTemplateNotFound + } + existing.Name = name + existing.Content = content + existing.UpdatedUnix = now + _, err = db.GetEngine(ctx).ID(existing.ID).Cols("name", "content", "updated_unix").Update(existing) + return err + } + template.UserID = opts.UserID + template.CreatedUnix = now + template.UpdatedUnix = now + _, err := db.GetEngine(ctx).Insert(template) + return err + }) +} + +func DeleteDevContainerTemplate(ctx context.Context, opts DevContainerTemplateDeleteOptions) error { + deleted, err := db.GetEngine(ctx).Where("id = ? AND user_id = ?", opts.ID, opts.UserID).Delete(new(codespace_model.DevContainerTemplate)) + if err != nil { + return err + } + if deleted == 0 { + return ErrDevContainerTemplateNotFound + } + return nil +} diff --git a/services/codespace/devcontainer_template_test.go b/services/codespace/devcontainer_template_test.go new file mode 100644 index 0000000000000..e0f0a99d75b4a --- /dev/null +++ b/services/codespace/devcontainer_template_test.go @@ -0,0 +1,40 @@ +// 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 TestDevContainerTemplateSettingsScope(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + global := insertServiceDevContainerTemplate(t, 0, "Global", `{"image":"debian:12"}`) + personal := insertServiceDevContainerTemplate(t, 2, "Personal", `{"image":"node:24"}`) + foreign := insertServiceDevContainerTemplate(t, 4, "Foreign", `{"image":"golang:1.25"}`) + + visible, err := listVisibleDevContainerTemplates(t.Context(), 2) + require.NoError(t, err) + require.Len(t, visible, 2) + assert.Equal(t, []int64{global.ID, personal.ID}, []int64{visible[0].ID, visible[1].ID}) + + require.ErrorIs(t, UpsertDevContainerTemplate(t.Context(), DevContainerTemplateUpsertOptions{ + UserID: 2, + ID: foreign.ID, + Name: "Changed", + Content: `{"image":"debian:13"}`, + }), ErrDevContainerTemplateNotFound) + + require.NoError(t, DeleteDevContainerTemplate(t.Context(), DevContainerTemplateDeleteOptions{ + UserID: 2, + ID: personal.ID, + })) + unittest.AssertNotExistsBean(t, &codespace_model.DevContainerTemplate{ID: personal.ID}) +} diff --git a/services/codespace/disabled_test.go b/services/codespace/disabled_test.go new file mode 100644 index 0000000000000..270d838c0ea9e --- /dev/null +++ b/services/codespace/disabled_test.go @@ -0,0 +1,148 @@ +// 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, + CodespaceID: codespaceIDByUUID(t, 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, RuntimeUuid: 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, + CodespaceID: codespaceIDByUUID(t, 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 = requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: creatingUUID, + OperationRVersion: 92, + GitSSHPublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.ErrorIs(t, err, ErrRequestRuntimeAccessStateUnavailable) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE 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, + }) + t.Cleanup(test.MockVariableValue(&setting.Codespace.Enabled, false)) + + _, 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, CodespaceID: codespaceIDByUUID(t, runningUUID)}) + require.ErrorIs(t, err, ErrInteractionStateUnavailable) + assert.EqualValues(t, 1, loadServiceCodespace(t, runningUUID).InteractionGeneration) +} diff --git a/services/codespace/fetch.go b/services/codespace/fetch.go new file mode 100644 index 0000000000000..f0ef812261279 --- /dev/null +++ b/services/codespace/fetch.go @@ -0,0 +1,744 @@ +// 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 { + 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.GetRuntimeUuid()); err != nil { + return err + } + if observed.GetOperationRversion() <= 0 { + return errors.New("observed operation_rversion must be positive") + } + if _, ok := seen[observed.GetRuntimeUuid()]; ok { + return errors.New("observed_operations contains duplicate codespace uuid") + } + seen[observed.GetRuntimeUuid()] = 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.GetRuntimeUuid()] = item.GetOperationRversion() + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).Where("uuid = ?", item.GetRuntimeUuid()).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", "id"). + Find(&rows); err != nil { + return err + } + grantTime := time.Now() + for _, row := range rows { + 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) + if err != nil || !has { + return err + } + if codespace.ManagerID != managerID || codespace.OperationStatus != codespace_model.OperationStatusRunning { + return nil + } + 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) + return nil + } + observedVersion, hasObserved := observedVersions[codespace.UUID] + if !hasObserved { + return nil + } + if observedVersion > codespace.OperationRVersion { + return ErrFetchStateHistoryConflict + } + if !setting.Codespace.Enabled && (codespace.OperationType == codespace_model.OperationCreate || codespace.OperationType == codespace_model.OperationResume) { + if int32(len(result.Operations)) < maxOperations { + result.Operations = append(result.Operations, buildAbortOperationPayload(codespace)) + } + return nil + } + if _, err := db.GetEngine(ctx).ID(codespace.ID).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{ + RuntimeUuid: codespace.UUID, + OperationRversion: codespace.OperationRVersion, + LeaseValidForMilliseconds: leaseMillis, + }) + return nil + } + if int32(len(result.Operations)) >= maxOperations { + return nil + } + payload, err := buildOperationPayload(ctx, codespace, leaseMillis) + if err != nil { + return err + } + result.Operations = append(result.Operations, payload) + return nil + }) + }) + if err != nil { + return err + } + } + return nil +} + +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", "id"). + 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) { + var summary *internalStateSummary + 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) + if err != nil || !has { + return err + } + if current.OperationRVersion != candidate.OperationRVersion || current.OperationStatus != codespace_model.OperationStatusQueued || !isQueuedExpired(current, grantTime) { + return nil + } + summary = operationTimeoutSummary(current, queuedTimeoutStatus(current.OperationType)) + return applyQueuedTimeout(ctx, current, grantTime.Unix()) + }) + }) + if err != nil { + return claimed, err + } + if summary != nil { + *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.ID).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 { + query := db.GetEngine(ctx). + Where("id = ? AND manager_id = ? AND operation_r_version = ? AND operation_type = ? AND operation_status = ? AND operation_trigger = ?", + codespace.ID, managerID, codespace.OperationRVersion, codespace.OperationType, codespace_model.OperationStatusRunning, codespace.OperationTrigger) + columns := []string{"operation_status", "operation_started_unix", "operation_deadline_unix"} + updates := &codespace_model.Codespace{OperationStatus: codespace_model.OperationStatusQueued} + if codespace.OperationType == codespace_model.OperationCreate { + query = query.And("status = ?", codespace_model.StatusCreating) + updates.ManagerID = 0 + columns = append(columns, "manager_id") + } + if _, releaseErr := query.Cols(columns...).Update(updates); releaseErr != nil { + return claimed, fmt.Errorf("build operation payload: %w; release claim: %v", err, releaseErr) + } + 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, + } + // Keep every scheduling predicate in the UPDATE so concurrent Managers cannot both claim a stale candidate. + query := db.GetEngine(ctx). + Where("id = ? AND operation_r_version = ? AND operation_type = ? AND operation_status = ? AND operation_trigger = ?", + candidate.ID, 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, + CodespaceId: codespace.ID, + RuntimeUuid: 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, + CodespaceId: codespace.ID, + RuntimeUuid: 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{} + switch codespace.DevContainerSource { + case codespace_model.DevContainerSourceRepository: + if strings.TrimSpace(codespace.DevContainerPath) == "" || strings.TrimSpace(codespace.DevContainerContent) != "" { + return nil, errors.New("invalid persisted Dev Container configuration") + } + devContainer.Source = &codespacev1.DevContainerConfiguration_RepositoryPath{RepositoryPath: codespace.DevContainerPath} + case codespace_model.DevContainerSourceTemplate: + if strings.TrimSpace(codespace.DevContainerPath) != "" || strings.TrimSpace(codespace.DevContainerContent) == "" { + return nil, errors.New("invalid persisted Dev Container configuration") + } + devContainer.Source = &codespacev1.DevContainerConfiguration_TemplateContent{TemplateContent: codespace.DevContainerContent} + default: + return nil, errors.New("invalid persisted Dev Container configuration") + } + return &codespacev1.CreateOperationPayload{ + Repository: &codespacev1.RepositoryCheckout{ + FullName: repository.FullName(), + CloneHttpUrl: httpCloneURL, + CloneSshUrl: sshCloneURL, + PreferredProtocol: gitProtocolValue, + StartRef: startRef, + CommitSha: codespace.CommitSHA, + }, + EnvironmentTag: codespace.EnvironmentTag, + GitIdentity: &codespacev1.GitIdentity{ + GiteaUsername: codespaceOwner.Name, + GitUserEmail: codespaceOwner.GetEmail(), + }, + DevContainer: devContainer, + RuntimeSettings: runtimeSettingsMessage(effectiveRuntimeSettings(codespace)), + }, 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..c9c11c11c2e07 --- /dev/null +++ b/services/codespace/fetch_test.go @@ -0,0 +1,654 @@ +// 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.GetRuntimeUuid()) + assert.EqualValues(t, 31, operation.GetOperationRversion()) + assert.EqualValues(t, setting.Codespace.OperationLeaseTimeout/time.Millisecond, operation.GetLeaseValidForMilliseconds()) + create := operation.GetCreate() + require.NotNil(t, create) + repository := create.GetRepository() + require.NotNil(t, repository) + assert.NotEmpty(t, repository.GetFullName()) + assert.NotEmpty(t, repository.GetCloneHttpUrl()) + assert.Empty(t, repository.GetCloneSshUrl()) + assert.Equal(t, "refs/heads/main", repository.GetStartRef()) + assert.Equal(t, codespacev1.GitProtocol_GIT_PROTOCOL_HTTP, repository.GetPreferredProtocol()) + 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 TestFetchOperationsReleasesCreateClaimWhenPayloadInvalid(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-101010101011" + 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(), + }) + _, err := db.GetEngine(t.Context()).Where("uuid = ?", codespaceUUID).Cols("dev_container_path").Update(&codespace_model.Codespace{DevContainerPath: ".devcontainer/devcontainer.json"}) + require.NoError(t, err) + + _, err = FetchOperations(t.Context(), manager, FetchOperationsOptions{ + StartupCapacityAvailable: 1, + AcceptedOperationTypes: []codespacev1.AcceptedOperationType{codespacev1.AcceptedOperationType_ACCEPTED_OPERATION_TYPE_CREATE}, + AcceptedCreateTags: []string{"default"}, + }) + require.Error(t, err) + row := loadServiceCodespace(t, codespaceUUID) + assert.Zero(t, row.ManagerID) + assert.Equal(t, codespace_model.OperationStatusQueued, row.OperationStatus) + assert.Zero(t, row.OperationStartedUnix) + assert.Zero(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].GetRuntimeUuid()) + 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", + DevContainerSource: codespace_model.DevContainerSourceTemplate, + DevContainerContent: `{"image":"mcr.microsoft.com/devcontainers/base:ubuntu"}`, + AutoStopMode: codespace_model.AutoStopModeDefault, + }) + require.NoError(t, err) + require.NotNil(t, payload.GetRepository()) + assert.Equal(t, "user2/repo1", payload.GetRepository().GetFullName()) + assert.Contains(t, payload.GetRepository().GetCloneHttpUrl(), "/user2/repo1.git") + assert.Equal(t, "refs/heads/branch2", payload.GetRepository().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", + DevContainerSource: codespace_model.DevContainerSourceTemplate, + DevContainerContent: `{"image":"mcr.microsoft.com/devcontainers/base:ubuntu"}`, + AutoStopMode: codespace_model.AutoStopModeDefault, + }) + require.NoError(t, err) + require.NotNil(t, payload.GetRepository()) + assert.Equal(t, "user12/repo10", payload.GetRepository().GetFullName()) + assert.Contains(t, payload.GetRepository().GetCloneHttpUrl(), "/user13/repo11.git") + assert.Equal(t, "refs/heads/branch2", payload.GetRepository().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) + require.NotNil(t, create.GetRepository()) + assert.NotEmpty(t, create.GetRepository().GetCloneHttpUrl()) + assert.NotEmpty(t, create.GetRepository().GetCloneSshUrl()) + assert.Equal(t, codespacev1.GitProtocol_GIT_PROTOCOL_SSH, create.GetRepository().GetPreferredProtocol()) +} + +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{ + {RuntimeUuid: runningCreateUUID, OperationRversion: 51}, + {RuntimeUuid: 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].GetRuntimeUuid()) + + 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_id = (SELECT id FROM codespace WHERE uuid = ?)", tc.uuid) + } else { + assertServiceNotExists(t, new(codespace_model.GiteaToken), "codespace_id = (SELECT id FROM codespace WHERE uuid = ?)", tc.uuid) + } + if tc.expectKey { + assertServiceExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE uuid = ?)", tc.uuid) + } else { + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE 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{{ + 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].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) +} + +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{ + {RuntimeUuid: renewedUUID, OperationRversion: 36}, + {RuntimeUuid: 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{{ + 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].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) +} + +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].GetRuntimeUuid()) + 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{{ + RuntimeUuid: 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/gateway_access.go b/services/codespace/gateway_access.go new file mode 100644 index 0000000000000..3ac569c43d1b0 --- /dev/null +++ b/services/codespace/gateway_access.go @@ -0,0 +1,64 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" +) + +type gatewayAccessFailure string + +const ( + gatewayAccessManagerOffline gatewayAccessFailure = "manager_offline" + gatewayAccessCodespaceNotFound gatewayAccessFailure = "codespace_not_found" + gatewayAccessManagerMismatch gatewayAccessFailure = "manager_mismatch" + gatewayAccessCodespaceNotRunning gatewayAccessFailure = "codespace_not_running" + gatewayAccessActiveOperation gatewayAccessFailure = "active_operation" + gatewayAccessMetadataRebuilding gatewayAccessFailure = "metadata_rebuilding" +) + +type gatewayRuntimeAccess struct { + codespace *codespace_model.Codespace + metadata runtimeMetadata +} + +func loadGatewayRuntimeAccess(ctx context.Context, managerID int64, codespaceUUID string, allowQueuedIdleStop bool) (*gatewayRuntimeAccess, gatewayAccessFailure, error) { + manager, err := loadCodespaceManager(ctx, managerID) + if err != nil { + return nil, "", err + } + if manager.RuntimeState != codespace_model.ManagerRuntimeStateOnline || isManagerOffline(manager) { + return nil, gatewayAccessManagerOffline, nil + } + + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).Where("uuid = ?", codespaceUUID).Get(codespace) + if err != nil { + return nil, "", err + } + if !has { + return nil, gatewayAccessCodespaceNotFound, nil + } + if codespace.ManagerID != managerID { + return nil, gatewayAccessManagerMismatch, nil + } + if codespace.Status != codespace_model.StatusRunning { + return nil, gatewayAccessCodespaceNotRunning, nil + } + if hasActiveOperation(codespace) && !(allowQueuedIdleStop && isQueuedIdleStop(codespace)) { + return nil, gatewayAccessActiveOperation, nil + } + + entry, hasEntry, err := getRuntimeMetadataEntry(codespaceUUID) + if err != nil { + return nil, "", err + } + if !hasEntry || !runtimeMetadataReadyForRunning(codespace, entry.Metadata) { + return nil, gatewayAccessMetadataRebuilding, nil + } + return &gatewayRuntimeAccess{codespace: codespace, metadata: entry.Metadata}, "", nil +} diff --git a/services/codespace/git_ssh_key.go b/services/codespace/git_ssh_key.go new file mode 100644 index 0000000000000..403b47ebf6c61 --- /dev/null +++ b/services/codespace/git_ssh_key.go @@ -0,0 +1,393 @@ +// 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/setting" + + "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 +} + +// 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.CodespaceID).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.ID).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.ID, 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.ID, publicKey.ID) +} + +func insertCodespaceGitSSHKeyBinding(ctx context.Context, codespaceID, keyID int64) error { + _, err := db.GetEngine(ctx).Insert(&codespace_model.SSHKey{ + CodespaceID: codespaceID, + 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, codespaceID int64) error { + relation := new(codespace_model.SSHKey) + has, err := db.GetEngine(ctx).ID(codespaceID).Get(relation) + if err != nil || !has { + return err + } + if _, err := db.GetEngine(ctx).ID(codespaceID).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 +} diff --git a/services/codespace/git_ssh_key_test.go b/services/codespace/git_ssh_key_test.go new file mode 100644 index 0000000000000..426b109f4f9c6 --- /dev/null +++ b/services/codespace/git_ssh_key_test.go @@ -0,0 +1,524 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "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 requestRuntimeGitSSHKey(ctx context.Context, manager *codespace_model.Manager, opts RequestRuntimeAccessOptions) ([]string, error) { + result, err := RequestRuntimeAccess(ctx, manager, opts) + if err != nil { + return nil, err + } + return result.GitSSHKnownHosts, nil +} + +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 := requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 15, + GitSSHPublicKey: 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 := requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 15, + GitSSHPublicKey: 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 := requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 20, + GitSSHPublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + _, err = db.GetEngine(t.Context()).Where("uuid = ?", 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()).Where("uuid = ?", 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()). + Where("authorization_id = ? AND target_repo_id = ? AND unit_type = ?", rule.AuthorizationID, rule.TargetRepoID, rule.UnitType). + 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 := requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 16, + GitSSHPublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.NoError(t, err) + original := loadServiceSSHKeyRelation(t, codespaceUUID) + + _, err = requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 16, + GitSSHPublicKey: 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 = requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 16, + GitSSHPublicKey: 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 := requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 17, + GitSSHPublicKey: 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 = requestRuntimeGitSSHKey(t.Context(), &staleManager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 18, + GitSSHPublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.ErrorIs(t, err, ErrRequestRuntimeAccessManagerOffline) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE 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 = requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 19, + GitSSHPublicKey: servicePublicKeyWire(t, testGitSSHPublicKey), + }) + require.ErrorIs(t, err, ErrRuntimeGitSSHKeyLoginRestricted) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) +} + +func TestRuntimeGitSSHKeyRejectsInvalidPublicKey(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + configureServiceGitSSHHostKey(t) + + manager := insertServiceManager(t) + markServiceManagerOnline(t, manager, `[{"tag":"default"}]`) + codespaceUUID := "45454545-4545-4454-8454-454545454545" + insertActiveCreateCodespaceForGitSSHKey(t, manager.ID, codespaceUUID, 1) + _, err := requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 1, + GitSSHPublicKey: []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 := requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 1, + GitSSHPublicKey: 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 := requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 1, + GitSSHPublicKey: 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 := requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 1, + GitSSHPublicKey: 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 := requestRuntimeGitSSHKey(t.Context(), manager, RequestRuntimeAccessOptions{ + CodespaceUUID: codespaceUUID, + OperationRVersion: 18, + GitSSHPublicKey: 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_id = (SELECT id FROM codespace WHERE 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(loadServiceCodespace(t, codespaceUUID).ID).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..dc2f1deac9d40 --- /dev/null +++ b/services/codespace/governance.go @@ -0,0 +1,371 @@ +// 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 { + ID int64 + 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{ + ID: codespace.ID, + 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).Where("uuid = ?", 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..13a2442e3b999 --- /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_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE 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..d5b6f1c7d9e61 --- /dev/null +++ b/services/codespace/idle_stop.go @@ -0,0 +1,183 @@ +// 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 ( + // 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).Where("uuid = ?", 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.ID).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 +} + +func codespaceRowLockKey(codespaceID int64) string { + return fmt.Sprintf("codespace_interaction_id_%d", codespaceID) +} 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..b0249b776ae72 --- /dev/null +++ b/services/codespace/interaction.go @@ -0,0 +1,199 @@ +// 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 + CodespaceID int64 +} + +// ContinueCodespaceResult contains the new interaction generation. +type ContinueCodespaceResult struct { + InteractionGeneration int64 +} + +// UpdateAutoStopOptions contains one creator auto-stop settings update. +type UpdateAutoStopOptions struct { + UserID int64 + CodespaceID int64 + 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.CodespaceID); err != nil { + return nil, err + } + + var result *ContinueCodespaceResult + 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.CodespaceID) + 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.CodespaceID); err != nil { + return nil, err + } + + var result *UpdateAutoStopResult + 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.CodespaceID) + 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.ID).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, codespaceID int64) error { + if userID <= 0 { + return errors.New("user_id must be positive") + } + if codespaceID <= 0 { + return errors.New("codespace_id must be positive") + } + return nil +} + +func loadCreatorCodespace(ctx context.Context, userID, codespaceID int64) (*codespace_model.Codespace, error) { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(codespaceID).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..97a71f5090d76 --- /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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, 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, + 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 new file mode 100644 index 0000000000000..d73e5ef8e21a1 --- /dev/null +++ b/services/codespace/inventory.go @@ -0,0 +1,304 @@ +// 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.GetRuntimeUuid()] = 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.GetRuntimeUuid()); err != nil { + return err + } + if _, ok := seen[instance.GetRuntimeUuid()]; ok { + return fmt.Errorf("duplicate codespace_uuid %q", instance.GetRuntimeUuid()) + } + seen[instance.GetRuntimeUuid()] = 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).Where("uuid = ?", instance.GetRuntimeUuid()).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) { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).Where("uuid = ?", instance.GetRuntimeUuid()).Get(codespace) + if err != nil { + return nil, err + } + result := &codespacev1.RuntimeInstanceResult{RuntimeUuid: instance.GetRuntimeUuid()} + if !has { + result.Action = codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_CLEANUP_LOCAL_RUNTIME + return result, nil + } + if codespace.ManagerID != managerID { + if codespace.ManagerID == 0 && codespace.Status == codespace_model.StatusCreating { + return result, nil + } + result.Action = codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_CLEANUP_LOCAL_RUNTIME + return result, nil + } + if codespace.Status == codespace_model.StatusFailed { + result.Action = codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_CLEANUP_LOCAL_RUNTIME + return result, nil + } + + 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, nil + } + if instance.GetObservedOperationRversion() > 0 { + result.Action = codespacev1.RuntimeReconcileAction_RUNTIME_RECONCILE_ACTION_CLEAR_OPERATION_CONTEXT + result.CurrentOperationRversion = codespace.OperationRVersion + return result, nil + } + 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, nil +} + +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, codespaceStateLockKey(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).Where("uuid = ?", 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, codespace_model.StatusFailed); err != nil { + return err + } + deleteRuntimeMetadata(codespace.UUID) + _, err := db.GetEngine(ctx).ID(codespace.ID).Cols( + "status", + "operation_type", + "operation_status", + "operation_trigger", + "operation_created_unix", + "operation_started_unix", + "operation_deadline_unix", + "updated_unix", + ).Update(codespace) + return err +} diff --git a/services/codespace/inventory_test.go b/services/codespace/inventory_test.go new file mode 100644 index 0000000000000..06b52a22638a8 --- /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{ + {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) + assert.Contains(t, err.Error(), "duplicate") + + result, err := ReportInstances(t.Context(), manager, ReportInstancesOptions{ + InventoryGeneration: 1, + Instances: []*codespacev1.RuntimeInstanceRef{ + {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) + 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{{ + RuntimeUuid: 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{ + {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) + 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{{ + RuntimeUuid: 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..64c0fdaf5f6d4 --- /dev/null +++ b/services/codespace/lifecycle.go @@ -0,0 +1,239 @@ +// 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" +) + +// 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 := 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).Where("uuid = ?", opts.CodespaceUUID).Get(codespace) + if err != nil { + return err + } + if !has { + response.ResourceAbsent = true + return nil + } + + // A stale final ends Manager work but must not overwrite a newer operation, so acknowledge it without changing state. + 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, codespace, 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, codespace *codespace_model.Codespace, operationRVersion int64) error { + hasToken, err := hasValidCurrentGiteaToken(ctx, codespace.ID) + if err != nil { + return err + } + if !hasToken { + return ErrFinalizeGiteaTokenRequired + } + hasMetadata, err := HasReadyRuntimeMetadata(ctx, codespace.UUID, 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, status); err != nil { + return err + } + if status != codespace_model.StatusRunning { + deleteRuntimeMetadata(codespace.UUID) + } + _, err := db.GetEngine(ctx).ID(codespace.ID).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, codespace *codespace_model.Codespace, status string) error { + switch status { + case codespace_model.StatusRunning: + return nil + case codespace_model.StatusStopped: + return deleteGiteaToken(ctx, codespace.ID) + case codespace_model.StatusFailed, codespace_model.StatusDeleting: + if err := deleteGiteaToken(ctx, codespace.ID); err != nil { + return err + } + return deleteGitSSHKey(ctx, codespace.ID) + default: + return nil + } +} + +func deleteCodespaceForFinal(ctx context.Context, codespaceUUID string) error { + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).Where("uuid = ?", codespaceUUID).Get(codespace) + if err != nil || !has { + return err + } + if err := deleteGiteaToken(ctx, codespace.ID); err != nil { + return err + } + if err := deleteGitSSHKey(ctx, codespace.ID); err != nil { + return err + } + if err := deleteCodespaceLog(ctx, codespaceUUID); err != nil { + return err + } + deleteRuntimeMetadata(codespaceUUID) + _, err = db.GetEngine(ctx).ID(codespace.ID).Delete(new(codespace_model.Codespace)) + return err +} + +func deleteGiteaToken(ctx context.Context, codespaceID int64) error { + _, err := db.GetEngine(ctx).ID(codespaceID).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..2b1615f6e08fd --- /dev/null +++ b/services/codespace/lifecycle_action.go @@ -0,0 +1,247 @@ +// 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 + CodespaceID int64 +} + +// 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, 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 { + 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.ID).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, 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.ID).Cols(cols...).Update(codespace) + return err +} + +func deleteUnboundCodespaceIfCurrent(ctx context.Context, codespace *codespace_model.Codespace) (bool, error) { + affected, err := db.GetEngine(ctx). + Where("id = ? AND user_id = ? AND manager_id = 0 AND status = ? AND operation_r_version = ? AND operation_type = ? AND operation_status = ? AND operation_trigger = ?", + codespace.ID, + 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.ID); err != nil { + return false, err + } + if err := deleteGitSSHKey(ctx, codespace.ID); 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") + } + 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).ID(opts.CodespaceID).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..2fac61d9949bb --- /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, CodespaceID: codespaceIDByUUID(t, 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, CodespaceID: codespaceIDByUUID(t, 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, CodespaceID: codespaceIDByUUID(t, 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, CodespaceID: codespaceIDByUUID(t, 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, CodespaceID: codespaceIDByUUID(t, 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, CodespaceID: codespaceIDByUUID(t, 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, CodespaceID: codespaceIDByUUID(t, 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_id = (SELECT id FROM codespace WHERE uuid = ?)", failedUUID) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE 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().Where("uuid = ?", 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, CodespaceID: codespaceIDByUUID(t, 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_id = (SELECT id FROM codespace WHERE uuid = ?)", tc.uuid) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE 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, CodespaceID: codespaceIDByUUID(t, runningUUID)}) + require.ErrorIs(t, err, ErrLifecycleActionStateUnavailable) + _, err = DeleteCodespace(t.Context(), LifecycleActionOptions{UserID: 1, CodespaceID: codespaceIDByUUID(t, 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, CodespaceID: codespaceIDByUUID(t, 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..af380c0fd0f00 --- /dev/null +++ b/services/codespace/lifecycle_test.go @@ -0,0 +1,399 @@ +// 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_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) + 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()) + + 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_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE 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{ + CodespaceID: loadServiceCodespace(t, codespaceUUID).ID, + 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" + if codespace.DevContainerSource == "" && codespace.DevContainerPath == "" && codespace.DevContainerContent == "" { + codespace.DevContainerSource = codespace_model.DevContainerSourceTemplate + codespace.DevContainerContent = `{"image":"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() + codespaceID := loadServiceCodespace(t, codespaceUUID).ID + _, err := insertNewGiteaToken(t.Context(), codespaceID) + require.NoError(t, err) + require.NoError(t, db.Insert(t.Context(), &codespace_model.SSHKey{ + CodespaceID: codespaceID, + 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()).Where("uuid = ?", codespaceUUID).Get(codespace) + require.NoError(t, err) + require.True(t, has) + 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) + 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..08670c39a0697 --- /dev/null +++ b/services/codespace/log.go @@ -0,0 +1,548 @@ +// 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 + CodespaceID int64 + 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).Where("uuid = ?", 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, manager.ID, opts.OperationRVersion, truncation); err != nil { + return err + } + nextOffset = codespace.LogSize + sizeExceeded = true + return nil + } + if err := appendEncodedLogLines(ctx, codespace, manager.ID, opts.OperationRVersion, 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 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} + } + 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.CodespaceID).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 + } + 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 + } + 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, expectedManagerID, expectedOperationRVersion int64, encoded []byte) error { + if len(encoded) == 0 { + return nil + } + previousSize := codespace.LogSize + if err := appendLogBytes(ctx, codespaceLogFilename(codespace.UUID), previousSize, encoded); err != nil { + return err + } + codespace.LogSize = previousSize + int64(len(encoded)) + query := db.GetEngine(ctx).Where("id = ? AND log_size = ?", codespace.ID, previousSize) + if expectedManagerID > 0 { + query = query.And("manager_id = ? AND operation_r_version = ? AND operation_status = ?", expectedManagerID, expectedOperationRVersion, codespace_model.OperationStatusRunning) + } + affected, err := query.Cols("log_size").Update(codespace) + if err != nil { + return err + } + if affected == 0 { + codespace.LogSize = previousSize + if expectedManagerID > 0 { + return ErrUpdateLogStaleOperation + } + return ErrUpdateLogNotFound + } + return nil +} + +func appendInternalStateSummary(ctx context.Context, summary *internalStateSummary) { + // Diagnostic summaries run after the lifecycle commit so logging failure cannot roll back an accepted state transition. + 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).Where("uuid = ?", 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, 0, 0, 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..3f8bd23fccb7f --- /dev/null +++ b/services/codespace/log_test.go @@ -0,0 +1,456 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "io" + "os" + "strings" + "testing" + "time" + + codespacev1 "gitea.dev/codespace-proto-go/codespace/v1" + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + "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 TestOperationLogAppendRollsBackWhenLifecycleChanges(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + manager := insertServiceManager(t) + codespaceUUID := "89898989-8989-4898-8989-898989898990" + 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(), + }) + stale := loadServiceCodespace(t, codespaceUUID) + _, err := db.GetEngine(t.Context()).ID(stale.ID).Cols("operation_type", "operation_status").Update(&codespace_model.Codespace{}) + require.NoError(t, err) + encoded, err := encodeLogLines([]*codespacev1.LogLine{{TimestampUnixNano: time.Now().UnixNano(), Message: "late"}}) + require.NoError(t, err) + + err = db.WithTx(t.Context(), func(ctx context.Context) error { + return appendEncodedLogLines(ctx, stale, manager.ID, 25, encoded) + }) + require.ErrorIs(t, err, ErrUpdateLogStaleOperation) + assert.Zero(t, loadServiceCodespace(t, codespaceUUID).LogSize) + _, err = dbfs.Open(t.Context(), codespaceLogDBFSPrefix+codespaceLogFilename(codespaceUUID)) + require.Error(t, err) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, 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..3866c705bd8c4 --- /dev/null +++ b/services/codespace/manager.go @@ -0,0 +1,272 @@ +// 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" + "gitea.dev/modules/globallock" + "gitea.dev/modules/json" +) + +var ( + tagPattern = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`) + sshHostKeyFingerprintRegexp = regexp.MustCompile(`^SHA256:[A-Za-z0-9+/]+={0,2}$`) +) + +const managerMaxEnvironments = 64 + +var ( + // 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") + // 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. +type DeclareManagerOptions struct { + GatewayURL string + GatewaySSHAddr string + Environments []*codespacev1.EnvironmentTag + Version string + RuntimeState codespacev1.ManagerRuntimeState + GatewaySSHHostKeyAlgorithm string + GatewaySSHHostKeyFingerprintSHA256 string + 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"` +} + +// 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 + } + if err := codespace_model.ValidateUUID(opts.RuntimeUUID); err != nil { + return "", err + } + return opts.RuntimeUUID, globallock.LockAndDo(ctx, codespaceStateLockKey(opts.RuntimeUUID), 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.CodespaceID).Get(codespace) + if err != nil { + return err + } + if !has || codespace.ManagerID != manager.ID || codespace.OperationRVersion != opts.OperationRVersion || + codespace.OperationType != codespace_model.OperationCreate || codespace.OperationStatus != codespace_model.OperationStatusRunning { + return ErrBindRuntimeIdentityNotFound + } + if codespace.UUID == opts.RuntimeUUID { + return nil + } + if codespace.UUID != "" { + return ErrBindRuntimeIdentityStateConflict + } + used, err := db.GetEngine(ctx).Where("uuid = ? AND id <> ?", opts.RuntimeUUID, codespace.ID).Exist(new(codespace_model.Codespace)) + if err != nil { + return err + } + 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 + }) + }) +} + +// 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 + } + now := time.Now().Unix() + updates := &codespace_model.Manager{ + 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( + "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 normalizeDeclareManagerOptions(opts DeclareManagerOptions) (DeclareManagerOptions, error) { + 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 +} 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..fb7dd28f03c92 --- /dev/null +++ b/services/codespace/manager_test.go @@ -0,0 +1,421 @@ +// 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.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, "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 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) + 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.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.LogMaxSize, int64(32*1024*1024))) + 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) + + 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", + 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..45f8945d15653 --- /dev/null +++ b/services/codespace/metadata.go @@ -0,0 +1,501 @@ +// 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, codespaceStateLockKey(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).Where("uuid = ?", 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)) > 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)) > 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") + } + // Retain metadata beyond one offline window so transient missed reports do not immediately remove usable endpoint state. + return cache.GetCache().PutJSON(runtimeMetadataCacheKey(codespaceUUID), entry, int64((setting.Codespace.ManagerOfflineTimeout*2)/time.Second)) +} + +func runtimeMetadataCacheKey(codespaceUUID string) string { + return "codespace:runtime-meta:" + 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..bf0e4f18b5cf9 --- /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 + CodespaceID int64 + 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 opts.CodespaceID <= 0 { + return nil, errors.New("codespace_id must be positive") + } + 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, 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).ID(opts.CodespaceID).Get(codespace) + if err != nil { + return err + } + if !has { + return ErrOpenEndpointNotFound + } + if codespace.UserID != opts.UserID { + return ErrOpenEndpointNotFound + } + if codespace.UUID == "" { + unavailableCategory = OpenTokenDeniedMetadataRebuilding + return nil + } + 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(codespace.UUID) + 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: codespace.UUID, + EndpointID: opts.EndpointID, + ManagerID: codespace.ManagerID, + IssuedUnix: now, + ExpiresUnix: now + int64(openTokenExpire/time.Second), + }); err != nil { + return err + } + redirectURL, err := gatewayOpenURL(gatewayURL, codespace.UUID, 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).Where("uuid = ?", 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 + } + // Consume the code before granting access so concurrent Gateway exchanges remain single-use. + 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, + RuntimeUuid: 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) { + // User activity cancels only a queued idle stop; explicit and already-running lifecycle operations retain ownership. + 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.ID).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 &openEndpointTarget{unavailableCategory: OpenTokenDeniedEndpointNotFound}, nil + } + targetURL, err := gatewayEndpointURL(gatewayURL, codespace.UUID, 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 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(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..2bd889ef448d4 --- /dev/null +++ b/services/codespace/open_token_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/db" + "gitea.dev/models/unittest" + "gitea.dev/modules/cache" + + "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, + CodespaceID: codespaceIDByUUID(t, 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().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))) + + 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, + CodespaceID: codespaceIDByUUID(t, 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, + CodespaceID: codespaceIDByUUID(t, codespaceUUID), + EndpointID: "workspace", + }) + require.NoError(t, err) + + _, err = db.GetEngine(t.Context()).Where("uuid = ?", 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(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(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, + CodespaceID: codespaceIDByUUID(t, 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(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, + CodespaceID: codespaceIDByUUID(t, 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..20d08bd46df1a --- /dev/null +++ b/services/codespace/permission_settings.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/models/perm" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unit" +) + +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 { + TargetRepoID int64 + UnitType unit.Type + 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{ + TargetRepoID: rule.TargetRepoID, UnitType: rule.UnitType, 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, targetRepoID int64, unitType unit.Type, 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). + Where("authorization_id = ? AND target_repo_id = ? AND unit_type = ?", authorization.ID, targetRepoID, unitType). + 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). + Where("authorization_id = ? AND target_repo_id = ? AND unit_type = ? AND granted_mode = ?", authorization.ID, targetRepoID, unitType, 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..35d1630fe92f7 --- /dev/null +++ b/services/codespace/permission_settings_test.go @@ -0,0 +1,77 @@ +// 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.TargetRepoID, rule.UnitType, perm.AccessModeRead), ErrPermissionAuthorizationNotFound) + require.ErrorIs(t, RevokePermissionAuthorization(t.Context(), 4, authorization.ID), ErrPermissionAuthorizationNotFound) + require.ErrorIs(t, ReducePermissionRepository(t.Context(), 2, authorization.ID, otherRule.TargetRepoID, otherRule.UnitType, perm.AccessModeRead), ErrPermissionAuthorizationNotFound) + + require.NoError(t, ReducePermissionRepository(t.Context(), 2, authorization.ID, rule.TargetRepoID, rule.UnitType, perm.AccessModeRead)) + loadedRule := new(codespace_model.PermissionRepository) + has, err := db.GetEngine(t.Context()). + Where("authorization_id = ? AND target_repo_id = ? AND unit_type = ?", authorization.ID, rule.TargetRepoID, rule.UnitType). + Get(loadedRule) + require.NoError(t, err) + require.True(t, has) + assert.Equal(t, perm.AccessModeRead, loadedRule.GrantedMode) + require.NoError(t, ReducePermissionRepository(t.Context(), 2, authorization.ID, rule.TargetRepoID, rule.UnitType, perm.AccessModeNone)) + require.ErrorIs(t, ReducePermissionRepository(t.Context(), 2, authorization.ID, rule.TargetRepoID, rule.UnitType, perm.AccessModeRead), ErrPermissionReductionInvalid) + loadedRule = new(codespace_model.PermissionRepository) + has, err = db.GetEngine(t.Context()). + Where("authorization_id = ? AND target_repo_id = ? AND unit_type = ?", authorization.ID, rule.TargetRepoID, rule.UnitType). + Get(loadedRule) + require.NoError(t, err) + require.True(t, has) + assert.Equal(t, perm.AccessModeNone, loadedRule.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.TargetRepoID, rule.UnitType, perm.AccessModeNone), ErrPermissionAuthorizationNotFound) +} diff --git a/services/codespace/public_endpoint.go b/services/codespace/public_endpoint.go new file mode 100644 index 0000000000000..0bc85be6ba8e9 --- /dev/null +++ b/services/codespace/public_endpoint.go @@ -0,0 +1,91 @@ +// 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/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, errors.New("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 + } + access, failure, err := loadGatewayRuntimeAccess(ctx, manager.ID, opts.CodespaceUUID, false) + if err != nil { + return nil, err + } + switch failure { + case gatewayAccessManagerOffline: + return denyPublicEndpoint(PublicEndpointDeniedManagerOffline), nil + case gatewayAccessCodespaceNotFound: + return denyPublicEndpoint(PublicEndpointDeniedCodespaceNotFound), nil + case gatewayAccessManagerMismatch: + return denyPublicEndpoint(PublicEndpointDeniedManagerMismatch), nil + case gatewayAccessCodespaceNotRunning: + return denyPublicEndpoint(PublicEndpointDeniedStateUnavailable), nil + case gatewayAccessActiveOperation: + return denyPublicEndpoint(PublicEndpointDeniedActiveOperation), nil + case gatewayAccessMetadataRebuilding: + return denyPublicEndpoint(PublicEndpointDeniedMetadataRebuilding), nil + } + endpoint, found := access.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..d25a364506e75 --- /dev/null +++ b/services/codespace/public_endpoint_test.go @@ -0,0 +1,208 @@ +// 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 { + endpointID, ok := endpoint["endpoint_id"].(string) + require.True(t, ok) + label, ok := endpoint["label"].(string) + require.True(t, ok) + public, ok := endpoint["public"].(bool) + require.True(t, ok) + metadataEndpoints = append(metadataEndpoints, &codespacev1.RuntimeEndpoint{ + EndpointId: endpointID, + Label: label, + Public: public, + }) + } + 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..a1a485f4e2f2f --- /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", "id"). + 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).Where("uuid = ?", 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", "id"). + 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).Where("uuid = ?", 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", "id"). + 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).Where("uuid = ?", 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..31c4a991c7e2c --- /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()).Where("uuid = ?", 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()).Where("uuid = ?", 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_id = (SELECT id FROM codespace WHERE uuid = ?)", queuedUUID) + assertServiceExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE 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_id = (SELECT id FROM codespace WHERE uuid = ?)", failedUUID) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE 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..9bd70b65fdb47 --- /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).Where("uuid = ?", 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, targetStatus); err != nil { + return err + } + deleteRuntimeMetadata(codespace.UUID) + _, err := db.GetEngine(ctx).ID(codespace.ID).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..eb0f57ff1a143 --- /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_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) + assertServiceExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE 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_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE 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_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) + assertServiceExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE 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..d21b20a863aaf --- /dev/null +++ b/services/codespace/session_revalidate.go @@ -0,0 +1,122 @@ +// 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" + 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 + } + + access, failure, err := loadGatewayRuntimeAccess(ctx, manager.ID, codespaceUUID, false) + if err != nil { + return nil, err + } + switch failure { + case gatewayAccessCodespaceNotFound: + return denyGatewaySession(SessionDeniedCodespaceNotFound), nil + case gatewayAccessManagerMismatch: + return denyGatewaySession(SessionDeniedManagerMismatch), nil + case gatewayAccessCodespaceNotRunning: + return denyGatewaySession(SessionDeniedCodespaceNotRunning), nil + case gatewayAccessManagerOffline, gatewayAccessActiveOperation: + return denyGatewaySession(SessionDeniedStateUnavailable), nil + case gatewayAccessMetadataRebuilding: + return denyGatewaySession(SessionDeniedMetadataRebuilding), nil + } + if userID != access.codespace.UserID { + return denyGatewaySession(SessionDeniedPermissionDenied), nil + } + + user, err := user_model.GetUserByID(ctx, access.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 + } + + endpoint, endpointFound := access.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.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.GetRuntimeUuid() + 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..fa5b05d2def2b --- /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, RuntimeUuid: 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, RuntimeUuid: 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, RuntimeUuid: 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, RuntimeUuid: 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, RuntimeUuid: 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, RuntimeUuid: 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..7053f905eef06 --- /dev/null +++ b/services/codespace/settings.go @@ -0,0 +1,575 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + "unicode" + + codespace_model "gitea.dev/models/codespace" + "gitea.dev/models/db" + user_model "gitea.dev/models/user" + "gitea.dev/modules/globallock" +) + +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") + // 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. +type ManagerSettingsOptions struct { + Scope string + UserID int64 +} + +// DeleteManagerOptions identifies one Manager deletion request. +type DeleteManagerOptions struct { + Scope string + UserID int64 + ManagerID int64 + Confirm bool +} + +// CreateManagerOptions contains the settings scope and Gitea-managed display name. +type CreateManagerOptions struct { + ManagerSettingsOptions + Name string +} + +// 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 Manager rows for settings pages. +type ManagerSettings struct { + Managers []*ManagerSettingsView +} + +// ManagerSettingsView contains fields shown on Manager settings pages. +type ManagerSettingsView struct { + ID int64 + Name string + HasSecret bool + 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 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 + } + 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 + } + views, err := settingsManagerViews(ctx, managers, opts.UserID) + if err != nil { + return nil, err + } + return &ManagerSettings{Managers: views}, 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 +} + +// 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 0, err + } + if err := validateManagerSettingsScope(ctx, opts.ManagerSettingsOptions); err != nil { + return 0, err + } + userID := opts.UserID + if opts.Scope == ManagerSettingsScopeSite { + userID = 0 + } + 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 { + return err + } + manager := &codespace_model.Manager{ + Name: name, + UserID: userID, + RuntimeState: codespace_model.ManagerRuntimeStateRecovering, + TagsJSON: "[]", + CreatedUnix: time.Now().Unix(), + } + if _, err := db.GetEngine(ctx).Insert(manager); err != nil { + return err + } + 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 + } + 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) { + 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 { + 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 { + 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(rows) == 0 { + break + } + for _, row := range rows { + if err := deleteManagerCodespace(ctx, managerID, row.UUID); 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 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("manager_id", "kind").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, + HasSecret: manager.SecretHash != "", + 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 manager.LastOnlineUnix == 0 { + view.RuntimeDisplayState = managerDisplayPending + } else 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 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") + } + 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 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 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).Where("uuid = ?", codespaceUUID).Get(codespace) + if err != nil || !has || codespace.ManagerID != managerID { + return err + } + return deleteCodespaceForFinal(ctx, codespaceUUID) + }) + }) +} diff --git a/services/codespace/settings_test.go b/services/codespace/settings_test.go new file mode 100644 index 0000000000000..628eb4ba002ef --- /dev/null +++ b/services/codespace/settings_test.go @@ -0,0 +1,226 @@ +// 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 TestManagerNameAndCredentialLifecycle(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + opts := CreateManagerOptions{ + ManagerSettingsOptions: ManagerSettingsOptions{Scope: ManagerSettingsScopeUser, UserID: 1}, + Name: " Personal Manager ", + } + managerID, err := CreateManager(t.Context(), opts) + require.NoError(t, err) + 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) + views, err := ListManagerSettings(t.Context(), ManagerSettingsOptions{Scope: ManagerSettingsScopeUser, UserID: 1}) + 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) { + 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()) + + 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{ + CodespaceID: loadServiceCodespace(t, codespaceUUID).ID, + TokenHash: "manager-delete-hash", + TokenSalt: "salt", + TokenLastEight: "last0001", + TokenEncrypted: "encrypted", + })) + require.NoError(t, db.Insert(t.Context(), &codespace_model.SSHKey{ + CodespaceID: loadServiceCodespace(t, codespaceUUID).ID, + 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_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) + assertServiceNotExists(t, new(codespace_model.SSHKey), "codespace_id = (SELECT id FROM codespace WHERE uuid = ?)", codespaceUUID) +} + +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()).Where("uuid = ?", 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 := CreateManager(t.Context(), CreateManagerOptions{ + ManagerSettingsOptions: ManagerSettingsOptions{ + Scope: ManagerSettingsScopeUser, + UserID: 3, + }, + Name: "Manager", + }) + require.ErrorContains(t, err, "not an individual") +} + +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..71c5599a6ecd2 --- /dev/null +++ b/services/codespace/ssh_auth.go @@ -0,0 +1,199 @@ +// 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 { + access, failure, err := loadGatewayRuntimeAccess(ctx, manager.ID, opts.CodespaceUUID, true) + if err != nil { + return err + } + switch failure { + case gatewayAccessCodespaceNotFound: + result = denySSHAuth(SSHAuthDeniedCodespaceNotFound) + return nil + case gatewayAccessManagerMismatch: + result = denySSHAuth(SSHAuthDeniedManagerMismatch) + return nil + case gatewayAccessCodespaceNotRunning: + result = denySSHAuth(SSHAuthDeniedCodespaceNotRunning) + return nil + case gatewayAccessManagerOffline, gatewayAccessActiveOperation: + result = denySSHAuth(SSHAuthDeniedStateUnavailable) + return nil + case gatewayAccessMetadataRebuilding: + result = denySSHAuth(SSHAuthDeniedMetadataRebuilding) + return nil + } + codespace := access.codespace + + 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..79592fc190428 --- /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()). + Where("uuid = ?", 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..3868b7d1f69d0 --- /dev/null +++ b/services/codespace/token.go @@ -0,0 +1,501 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + asymkey_model "gitea.dev/models/asymkey" + 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" + asymkey_service "gitea.dev/services/asymkey" +) + +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 +} + +type runtimeAccessPreparation struct { + credentials *requestRuntimeCredentialsResult + codespace *codespace_model.Codespace + user *user_model.User +} + +// 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 err := validateRuntimeAccessRequest(manager, opts.CodespaceUUID, opts.OperationRVersion); err != nil { + return nil, err + } + + var ( + prepared *runtimeAccessPreparation + knownHosts []string + ) + err := globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + var err error + prepared, err = prepareRuntimeAccessLocked(ctx, manager, requestRuntimeCredentialsOptions{ + CodespaceUUID: opts.CodespaceUUID, + OperationRVersion: opts.OperationRVersion, + }) + if err != nil { + return err + } + key, err := normalizeGitSSHPublicKey(opts.GitSSHPublicKey) + if err != nil { + return err + } + knownHosts, err = availableGitSSHKnownHostsLines() + if err != nil { + return err + } + canUseCodespace, err := codespaceUserCanLogIn(ctx, prepared.user) + if err != nil { + return err + } + if !canUseCodespace { + return ErrRuntimeGitSSHKeyLoginRestricted + } + // Share the fingerprint lock with user and deploy key creation so their global uniqueness checks cannot race. + return globallock.LockAndDo(ctx, asymkey_model.PublicKeyFingerprintLockKey(key.Fingerprint), func(ctx context.Context) error { + return ensureGitSSHKeyBinding(ctx, prepared.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 &RequestRuntimeAccessResult{ + Token: prepared.credentials.Token, + ServerURL: prepared.credentials.ServerURL, + Secrets: prepared.credentials.Secrets, + GitSSHKnownHosts: knownHosts, + }, nil +} + +func prepareRuntimeAccessLocked(ctx context.Context, manager *codespace_model.Manager, opts requestRuntimeCredentialsOptions) (*runtimeAccessPreparation, error) { + var ( + token string + secrets []RuntimeSecret + codespace *codespace_model.Codespace + user *user_model.User + ) + err := 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, codespace.ID) + if err != nil { + return err + } + if ok { + token = existingToken + return nil + } + generatedToken, err := insertNewGiteaToken(ctx, codespace.ID) + if err != nil { + return err + } + token = generatedToken + return nil + }) + if err != nil { + return nil, err + } + return &runtimeAccessPreparation{ + credentials: &requestRuntimeCredentialsResult{ + Token: token, + ServerURL: setting.AppURL, + Secrets: secrets, + }, + codespace: codespace, + user: user, + }, nil +} + +func validateRuntimeAccessRequest(manager *codespace_model.Manager, codespaceUUID string, operationRVersion int64) error { + if !setting.Codespace.Enabled { + return ErrRequestRuntimeAccessStateUnavailable + } + if manager == nil || manager.ID <= 0 { + return errors.New("manager is required") + } + if err := codespace_model.ValidateUUID(codespaceUUID); err != nil { + return err + } + if operationRVersion <= 0 { + return errors.New("operation_rversion must be positive") + } + return 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).Where("uuid = ?", 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 !IsGiteaTokenPlaintext(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.id = codespace_gitea_token.codespace_id"). + 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, codespaceID int64) (string, bool, error) { + row := new(codespace_model.GiteaToken) + has, err := db.GetEngine(ctx).ID(codespaceID).Get(row) + if err != nil || !has { + return "", false, err + } + token, err := secret_module.DecryptSecret(setting.SecretKey, row.TokenEncrypted) + if err != nil || !verifyCodespaceGiteaToken(row, token) { + if _, deleteErr := db.GetEngine(ctx).ID(codespaceID).Delete(new(codespace_model.GiteaToken)); deleteErr != nil { + return "", false, deleteErr + } + return "", false, nil + } + return token, true, nil +} + +func hasValidCurrentGiteaToken(ctx context.Context, codespaceID int64) (bool, error) { + row := new(codespace_model.GiteaToken) + has, err := db.GetEngine(ctx).ID(codespaceID).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 verifyCodespaceGiteaToken(row, token), nil +} + +func insertNewGiteaToken(ctx context.Context, codespaceID int64) (string, error) { + token := codespaceTokenPrefix + hex.EncodeToString(util.CryptoRandomBytes(32)) + salt := util.CryptoRandomString(10) + encrypted, err := secret_module.EncryptSecret(setting.SecretKey, token) + if err != nil { + return "", err + } + row := &codespace_model.GiteaToken{ + CodespaceID: codespaceID, + 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, codespaceID) + 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 !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 +} + +// 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) +} diff --git a/services/codespace/token_test.go b/services/codespace/token_test.go new file mode 100644 index 0000000000000..220518f5bfec9 --- /dev/null +++ b/services/codespace/token_test.go @@ -0,0 +1,376 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "context" + "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" + "gitea.dev/modules/globallock" + 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 requestRuntimeCredentials(ctx context.Context, manager *codespace_model.Manager, opts requestRuntimeCredentialsOptions) (*requestRuntimeCredentialsResult, error) { + if err := validateRuntimeAccessRequest(manager, opts.CodespaceUUID, opts.OperationRVersion); err != nil { + return nil, err + } + var result *requestRuntimeCredentialsResult + err := globallock.LockAndDo(ctx, codespaceStateLockKey(opts.CodespaceUUID), func(ctx context.Context) error { + prepared, err := prepareRuntimeAccessLocked(ctx, manager, opts) + if err == nil { + result = prepared.credentials + } + return err + }) + return result, err +} + +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()).Where("uuid = ?", 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{ + CodespaceID: loadServiceCodespace(t, codespaceUUID).ID, + 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_id = (SELECT id FROM codespace WHERE 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_id = (SELECT id FROM codespace WHERE 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_id = (SELECT id FROM codespace WHERE 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_id = (SELECT id FROM codespace WHERE 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()). + Where("uuid = ?", 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(loadServiceCodespace(t, codespaceUUID).ID).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..a474558733491 --- /dev/null +++ b/services/codespace/user_delete.go @@ -0,0 +1,147 @@ +// 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 owned 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 + } + 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("id"). + 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).Where("uuid = ?", 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..81c16351b74c4 --- /dev/null +++ b/services/codespace/user_delete_test.go @@ -0,0 +1,69 @@ +// 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) + 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.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) + assertServiceNotExists(t, new(codespace_model.UserSecretRepository), "secret_id = ? AND repo_id = ?", secretRepository.SecretID, secretRepository.RepoID) +} diff --git a/services/codespace/user_secret.go b/services/codespace/user_secret.go new file mode 100644 index 0000000000000..d983b71dc52a3 --- /dev/null +++ b/services/codespace/user_secret.go @@ -0,0 +1,627 @@ +// 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 { + // Acquire repository locks in ID order so concurrent secret updates cannot deadlock. + 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..8b114a1ef5ca4 --- /dev/null +++ b/services/codespace/view.go @@ -0,0 +1,577 @@ +// 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 + CodespaceID int64 +} + +// 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 { + ID int64 + 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.id") + 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 opts.CodespaceID <= 0 { + return nil, errors.New("codespace_id must be positive") + } + codespace := new(codespace_model.Codespace) + has, err := db.GetEngine(ctx).ID(opts.CodespaceID).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{ + ID: codespace.ID, + 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 + 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 { + 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.ID) + "/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.ID) + "/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(codespaceID int64) string { + return "/-/codespaces/" + strconv.FormatInt(codespaceID, 10) +} diff --git a/services/codespace/view_test.go b/services/codespace/view_test.go new file mode 100644 index 0000000000000..6bcc74bfba930 --- /dev/null +++ b/services/codespace/view_test.go @@ -0,0 +1,337 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package codespace + +import ( + "strconv" + "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/"+strconv.FormatInt(row.ID, 10)+"/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()).Where("uuid = ?", 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()).Where("uuid = ?", 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()).Where("uuid = ?", 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()).Where("uuid = ?", 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()).Where("uuid = ?", 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, CodespaceID: codespaceIDByUUID(t, 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, CodespaceID: codespaceIDByUUID(t, 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, CodespaceID: codespaceIDByUUID(t, 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, CodespaceID: codespaceIDByUUID(t, 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, CodespaceID: codespaceIDByUUID(t, offlineUUID)}) + 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" + insertServiceCodespace(t, manager.ID, &codespace_model.Codespace{ + UUID: onlineUUID, + Status: codespace_model.StatusStopped, + }) + + 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) + 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, + }) + require.NoError(t, err) + 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/context/api.go b/services/context/api.go index 5affd8b277873..7cde953540331 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" @@ -51,12 +53,58 @@ 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. +// For other tokens, 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. 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 GetAPIContext(req) diff --git a/services/context/api_test.go b/services/context/api_test.go index 8a628d4db2a64..ecd9636aa4120 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)}} + 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)}} + 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 8969188675a8e..9a62d92381cf8 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -196,6 +196,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 97963461685ff..7c04b4016404e 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 { @@ -118,6 +142,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_basic.go b/services/cron/tasks_basic.go index 06be3bdaf86c3..ceebed75d9bf7 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" @@ -149,6 +150,26 @@ func registerCleanupPackages() { }) } +func registerReconcileCodespaces() { + type reconcileCodespacesConfig struct { + BaseConfig + OlderThan time.Duration + } + 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 *reconcileCodespacesConfig) error { + _, err := codespace_service.ReconcileCodespaces(ctx, codespace_service.ReconcileCodespacesOptions{ + FailedOlderThan: config.OlderThan, + }) + return err + }) +} + func registerSyncRepoLicenses() { RegisterTaskFatal("sync_repo_licenses", &BaseConfig{ Enabled: false, @@ -175,5 +196,6 @@ func initBasicTasks() { if setting.Packages.Enabled { registerCleanupPackages() } + registerReconcileCodespaces() registerSyncRepoLicenses() } 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 6d5df20b123a5..3a4cbd2c1318f 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -473,9 +473,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 382cd0a61760b..62c3e8e61c1db 100644 --- a/services/org/org.go +++ b/services/org/org.go @@ -56,29 +56,23 @@ 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 org.Type != user_model.UserTypeOrganization { + return fmt.Errorf("%s is a user not an organization", org.Name) + } + // outside the transaction below, because each repository deletion owns one and deletes storage after committing if purge { if err := repo_service.DeleteOwnerRepositoriesDirectly(ctx, org.AsUser()); err != nil { return err } + } else if err := checkDeleteOrganizationPreconditions(ctx, org); err != nil { + return err } if err := db.WithTx(ctx, func(ctx context.Context) error { - // 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} - } - - // 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 := checkDeleteOrganizationPreconditions(ctx, org); err != nil { + return err } - if err := deleteOrganization(ctx, org); err != nil { return fmt.Errorf("DeleteOrganization: %w", err) } @@ -108,6 +102,24 @@ func DeleteOrganization(ctx context.Context, org *org_model.Organization, purge return nil } +func checkDeleteOrganizationPreconditions(ctx context.Context, org *org_model.Organization) error { + // 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} + } + + // 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} + } + 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..aefa1359bdfc3 --- /dev/null +++ b/services/repository/codespace_test.go @@ -0,0 +1,119 @@ +// 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() + codespace := &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)) + require.NoError(t, db.Insert(t.Context(), &codespace_model.GiteaToken{ + CodespaceID: codespace.ID, + TokenHash: "codespace-token-hash", + TokenSalt: "salt", + TokenLastEight: "87654321", + TokenEncrypted: "encrypted", + })) + + require.NoError(t, DeleteRepositoryDirectly(t.Context(), targetRepo.ID)) + assertRepositoryServiceNotExists(t, new(codespace_model.PermissionRepository), "authorization_id = ? AND target_repo_id = ? AND unit_type = ?", rule.AuthorizationID, rule.TargetRepoID, rule.UnitType) + unittest.AssertExistsAndLoadBean(t, &codespace_model.PermissionAuthorization{ID: authorization.ID}) + assertRepositoryServiceNotExists(t, new(codespace_model.UserSecretRepository), "secret_id = ? AND repo_id = ?", targetSecretRepository.SecretID, targetSecretRepository.RepoID) + has, err := db.GetEngine(t.Context()).Where("secret_id = ? AND repo_id = ?", sourceSecretRepository.SecretID, sourceSecretRepository.RepoID).Exist(new(codespace_model.UserSecretRepository)) + require.NoError(t, err) + assert.True(t, has) + + require.NoError(t, DeleteRepositoryDirectly(t.Context(), repo.ID)) + + unittest.AssertNotExistsBean(t, &repo_model.Repository{ID: repo.ID}) + persistedCodespace := new(codespace_model.Codespace) + has, err = db.GetEngine(t.Context()).Where("uuid = ?", codespaceUUID).Get(persistedCodespace) + require.NoError(t, err) + require.True(t, has) + assert.Zero(t, persistedCodespace.RepoID) + assert.Zero(t, persistedCodespace.PermissionAuthorizationID) + assert.Equal(t, codespace_model.StatusRunning, persistedCodespace.Status) + assert.EqualValues(t, 200, persistedCodespace.UpdatedUnix) + assert.EqualValues(t, 3, persistedCodespace.InteractionGeneration) + assert.EqualValues(t, 4, persistedCodespace.RuntimeGeneration) + token := new(codespace_model.GiteaToken) + has, err = db.GetEngine(t.Context()).ID(codespace.ID).Get(token) + require.NoError(t, err) + require.True(t, has) + unittest.AssertNotExistsBean(t, &codespace_model.PermissionAuthorization{ID: authorization.ID}) + assertRepositoryServiceNotExists(t, new(codespace_model.UserSecretRepository), "secret_id = ? AND repo_id = ?", sourceSecretRepository.SecretID, sourceSecretRepository.RepoID) + unittest.AssertExistsAndLoadBean(t, &codespace_model.UserSecret{ID: secret.ID}) +} + +func assertRepositoryServiceNotExists(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/repository/delete.go b/services/repository/delete.go index cd3f49144602e..a272466aafc9f 100644 --- a/services/repository/delete.go +++ b/services/repository/delete.go @@ -11,6 +11,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" @@ -25,6 +26,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" @@ -54,6 +56,13 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams if db.InTransaction(ctx) { return errors.New("DeleteRepositoryDirectly must not be called within a transaction, it deletes storage once its own transaction commits") } + + 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 @@ -101,6 +110,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 } @@ -388,6 +401,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 5c58d326e7efc..d1c96c368c6b4 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -38,13 +38,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) @@ -414,7 +415,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) @@ -436,7 +437,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 cc1d359aa182e..e940405dad4ab 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -26,6 +26,7 @@ import ( "gitea.dev/services/agit" asymkey_service "gitea.dev/services/asymkey" "gitea.dev/services/audit" + codespace_service "gitea.dev/services/codespace" org_service "gitea.dev/services/org" "gitea.dev/services/packages" container_service "gitea.dev/services/packages/container" @@ -169,6 +170,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. @@ -226,43 +231,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 } @@ -294,6 +286,32 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { return nil } +func checkDeleteUserPreconditions(ctx context.Context, u *user_model.User) error { + // 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} + } + 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 5d275d71f9482..e9039902f14db 100644 --- a/services/user/user_test.go +++ b/services/user/user_test.go @@ -12,6 +12,7 @@ import ( activities_model "gitea.dev/models/activities" audit_model "gitea.dev/models/audit" "gitea.dev/models/auth" + codespace_model "gitea.dev/models/codespace" "gitea.dev/models/db" issues_model "gitea.dev/models/issues" "gitea.dev/models/organization" @@ -146,6 +147,52 @@ 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) + codespaceUUID := "71717171-7171-4171-8171-717171717171" + codespace := &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)) + require.NoError(t, db.Insert(t.Context(), &codespace_model.GiteaToken{ + CodespaceID: codespace.ID, + 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.Codespace), "uuid = ?", codespaceUUID) + assertUserTestNotExists(t, new(codespace_model.GiteaToken), "codespace_id = ?", codespace.ID) +} + func TestRenameUser(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 21}) @@ -272,3 +319,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 4f821a9e660fa..470eb7f4c23cf 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -90,6 +90,19 @@ {{end}} + {{if .EnableCodespaces}} +
+ {{ctx.Locale.Tr "codespace.title"}} + +
+ {{end}}
{{ctx.Locale.Tr "admin.config"}}