From b6ab4733959176aacfb25183e9f2f5e57195e35d Mon Sep 17 00:00:00 2001 From: Earl Warren Date: Sun, 9 Jul 2023 14:52:21 +0200 Subject: [PATCH] [CLI] implement forgejo-cli (cherry picked from commit 2555e315f7561302484b15576d34c5da0d4cdb12) (cherry picked from commit 51b9c9092e21a451695ee0154e7d49753574f525) [CLI] implement forgejo-cli (squash) support initDB (cherry picked from commit 5c31ae602a45f1d9a90b86bece5393bc9faddf25) (cherry picked from commit bbf76489a73bad83d68ca7c8e7a75cf8e27b2198) Conflicts: because of d0dbe52e76f3038777c3b50066e3636105387ca3 upgrade to https://pkg.go.dev/github.com/urfave/cli/v2 (cherry picked from commit b6c1bcc008fcff0e297d570a0069bf41bc74e53d) [CLI] implement forgejo-cli actions (cherry picked from commit 08be2b226e46d9f41e08f66e936b317bcfb4a257) (cherry picked from commit b6cfa88c6e2ae00e30c832ce4cf93c9e3f2cd6e4) (cherry picked from commit 59704200de59b65a4f37c39569a3b43e1ee38862) [CLI] implement forgejo-cli actions generate-secret (cherry picked from commit 6f7905c8ecf17d5f74ac9a71a453d6768c212b6d) (cherry picked from commit e085d6d2737e6238a4ff00f19f40cf839ac16b34) [CLI] implement forgejo-cli actions generate-secret (squash) NoInit (cherry picked from commit 962c944eb20268a394030495c3caab3e3d4bd8b7) [CLI] implement forgejo-cli actions register (cherry picked from commit 2f95143000e4ccc94ef14332777b58fe778edbd6) (cherry picked from commit 42f2f8731e876564b6627a43a248f262f50c04cd) [CLI] implement forgejo-cli actions register (squash) no private Do not go through the private API, directly modify the database (cherry picked from commit 1ba7c0d39d0ecd190b7d9c517bd26af6c84341aa) [CLI] implement forgejo-cli actions (cherry picked from commit 6f7905c8ecf17d5f74ac9a71a453d6768c212b6d) (cherry picked from commit e085d6d2737e6238a4ff00f19f40cf839ac16b34) [CLI] implement forgejo-cli actions generate-secret (squash) NoInit (cherry picked from commit 962c944eb20268a394030495c3caab3e3d4bd8b7) (cherry picked from commit 4c121ef022597e66d902c17e0f46839c26924b18) Conflicts: cmd/forgejo/actions.go tests/integration/cmd_forgejo_actions_test.go (cherry picked from commit 36997a48e38286579850abe4b55e75a235b56537) [CLI] implement forgejo-cli actions (squash) restore --version Refs: https://codeberg.org/forgejo/forgejo/issues/1134 (cherry picked from commit 9739eb52d8f94d32f61068d7209958e8d2582818) [CI] implement forgejo-cli (squash) the actions subcommand needs config (cherry picked from commit def638475122a26082ab3835842c84cd03839154) Conflicts: cmd/main.go https://codeberg.org/forgejo/forgejo/pulls/1209 (cherry picked from commit a1758a391043123903607338cb11490161ac946d) (cherry picked from commit 935fa650c77b151752a58f621d846b166b97cd79) (cherry picked from commit cd21026bc94922043dce8e2a5baba68111d1e569) (cherry picked from commit 1700b8973a58f0fc3469492d8a39b931019d2461) (cherry picked from commit 1def42a37945cfe88947803f9afe9468fb8798fe) (cherry picked from commit 839d97521d59a012b06e6c2b9b0655c56b41b6cd) (cherry picked from commit fd8c13be6b45f9aa939be482c0a4e5a60c89344c) (cherry picked from commit 588e5d552f044d91218a07fa46e84259d4892c5d) (cherry picked from commit 151a726620f662ff9af37316dfda38a6bd6744bb) [v1.22] [CLI] implement forgejo-cli https://codeberg.org/forgejo/forgejo/pulls/1541 (cherry picked from commit 46708de7b9a3efac74aced8361327a39f45b6892) (cherry picked from commit a8e5c1369ee3ee197579a30aeba519b4384360aa) (cherry picked from commit c8a32aaf24fd851927432f140fcc59a274824d33) Conflicts: models/actions/main_test.go https://codeberg.org/forgejo/forgejo/pulls/1656 (cherry picked from commit 79f4553063c4f4ee70c98f95d9e62facd9d33c67) (cherry picked from commit 0379da0cf5b14e7915f2f38502bd00036723071d) (cherry picked from commit 331d58c085d6533ebcc528c1ac69d4f99e8e9acd) (cherry picked from commit 89705502c477ec833bd7ce46c3cedc53fbd454bc) (cherry picked from commit 4723d5febf4a5748b2ca038bc95235995ebb8c11) (cherry picked from commit e71b26013039d5d029ec4c38befd25e6a447b3f1) (cherry picked from commit 6a376a5b48b0b5187f492ddd73c72896cc8ae0a8) Conflicts: cmd/main.go https://codeberg.org/forgejo/forgejo/pulls/1969 (cherry picked from commit 6ba97cf4b5bae19426fef9d65a20bc5527e41a90) (cherry picked from commit e0a6ebfeca1ff20d53fe8d0baf4a737d6e10fce1) (cherry picked from commit 5702aeab2d25fa1f79fb1d11ec359a5460dc0f91) (cherry picked from commit f919c4d6c11423ac2d3ab624d9a6390661c07aa7) (cherry picked from commit a26799a88aa2f320b498372717019fa601545931) --- cmd/forgejo/actions.go | 243 ++++++++++++++++++ cmd/forgejo/forgejo.go | 147 +++++++++++ cmd/main.go | 37 ++- models/actions/forgejo.go | 68 +++++ models/actions/forgejo_test.go | 29 +++ modules/private/forgejo_actions.go | 32 +++ routers/private/actions.go | 7 +- tests/integration/cmd_forgejo_actions_test.go | 209 +++++++++++++++ tests/integration/cmd_forgejo_test.go | 36 +++ 9 files changed, 806 insertions(+), 2 deletions(-) create mode 100644 cmd/forgejo/actions.go create mode 100644 cmd/forgejo/forgejo.go create mode 100644 models/actions/forgejo.go create mode 100644 models/actions/forgejo_test.go create mode 100644 modules/private/forgejo_actions.go create mode 100644 tests/integration/cmd_forgejo_actions_test.go create mode 100644 tests/integration/cmd_forgejo_test.go diff --git a/cmd/forgejo/actions.go b/cmd/forgejo/actions.go new file mode 100644 index 0000000000..afda831227 --- /dev/null +++ b/cmd/forgejo/actions.go @@ -0,0 +1,243 @@ +// Copyright The Forgejo Authors. +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "encoding/hex" + "fmt" + "io" + "os" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/setting" + private_routers "code.gitea.io/gitea/routers/private" + + "github.com/urfave/cli/v2" +) + +func CmdActions(ctx context.Context) *cli.Command { + return &cli.Command{ + Name: "actions", + Usage: "Commands for managing Forgejo Actions", + Subcommands: []*cli.Command{ + SubcmdActionsGenerateRunnerToken(ctx), + SubcmdActionsGenerateRunnerSecret(ctx), + SubcmdActionsRegister(ctx), + }, + } +} + +func SubcmdActionsGenerateRunnerToken(ctx context.Context) *cli.Command { + return &cli.Command{ + Name: "generate-runner-token", + Usage: "Generate a new token for a runner to use to register with the server", + Action: prepareWorkPathAndCustomConf(ctx, func(cliCtx *cli.Context) error { return RunGenerateActionsRunnerToken(ctx, cliCtx) }), + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "scope", + Aliases: []string{"s"}, + Value: "", + Usage: "{owner}[/{repo}] - leave empty for a global runner", + }, + }, + } +} + +func SubcmdActionsGenerateRunnerSecret(ctx context.Context) *cli.Command { + return &cli.Command{ + Name: "generate-secret", + Usage: "Generate a secret suitable for input to the register subcommand", + Action: func(cliCtx *cli.Context) error { return RunGenerateSecret(ctx, cliCtx) }, + } +} + +func SubcmdActionsRegister(ctx context.Context) *cli.Command { + return &cli.Command{ + Name: "register", + Usage: "Idempotent registration of a runner using a shared secret", + Action: prepareWorkPathAndCustomConf(ctx, func(cliCtx *cli.Context) error { return RunRegister(ctx, cliCtx) }), + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "secret", + Usage: "the secret the runner will use to connect as a 40 character hexadecimal string", + }, + &cli.StringFlag{ + Name: "secret-stdin", + Usage: "the secret the runner will use to connect as a 40 character hexadecimal string, read from stdin", + }, + &cli.StringFlag{ + Name: "secret-file", + Usage: "path to the file containing the secret the runner will use to connect as a 40 character hexadecimal string", + }, + &cli.StringFlag{ + Name: "scope", + Aliases: []string{"s"}, + Value: "", + Usage: "{owner}[/{repo}] - leave empty for a global runner", + }, + &cli.StringFlag{ + Name: "labels", + Value: "", + Usage: "comma separated list of labels supported by the runner (e.g. docker,ubuntu-latest,self-hosted) (not required since v1.21)", + }, + &cli.StringFlag{ + Name: "name", + Value: "runner", + Usage: "name of the runner (default runner)", + }, + &cli.StringFlag{ + Name: "version", + Value: "", + Usage: "version of the runner (not required since v1.21)", + }, + }, + } +} + +func readSecret(ctx context.Context, cliCtx *cli.Context) (string, error) { + if cliCtx.IsSet("secret") { + return cliCtx.String("secret"), nil + } + if cliCtx.IsSet("secret-stdin") { + buf, err := io.ReadAll(ContextGetStdin(ctx)) + if err != nil { + return "", err + } + return string(buf), nil + } + if cliCtx.IsSet("secret-file") { + path := cliCtx.String("secret-file") + buf, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(buf), nil + } + return "", fmt.Errorf("at least one of the --secret, --secret-stdin, --secret-file options is required") +} + +func validateSecret(secret string) error { + secretLen := len(secret) + if secretLen != 40 { + return fmt.Errorf("the secret must be exactly 40 characters long, not %d: generate-secret can provide a secret matching the requirements", secretLen) + } + if _, err := hex.DecodeString(secret); err != nil { + return fmt.Errorf("the secret must be an hexadecimal string: %w", err) + } + return nil +} + +func RunRegister(ctx context.Context, cliCtx *cli.Context) error { + if !ContextGetNoInit(ctx) { + var cancel context.CancelFunc + ctx, cancel = installSignals(ctx) + defer cancel() + + if err := initDB(ctx); err != nil { + return err + } + } + setting.MustInstalled() + + secret, err := readSecret(ctx, cliCtx) + if err != nil { + return err + } + if err := validateSecret(secret); err != nil { + return err + } + scope := cliCtx.String("scope") + labels := cliCtx.String("labels") + name := cliCtx.String("name") + version := cliCtx.String("version") + + // + // There are two kinds of tokens + // + // - "registration token" only used when a runner interacts to + // register + // + // - "token" obtained after a successful registration and stored by + // the runner to authenticate + // + // The register subcommand does not need a "registration token", it + // needs a "token". Using the same name is confusing and secret is + // preferred for this reason in the cli. + // + // The ActionsRunnerRegister argument is token to be consistent with + // the internal naming. It is still confusing to the developer but + // not to the user. + // + owner, repo, err := private_routers.ParseScope(ctx, scope) + if err != nil { + return err + } + + runner, err := actions_model.RegisterRunner(ctx, owner, repo, secret, strings.Split(labels, ","), name, version) + if err != nil { + return fmt.Errorf("error while registering runner: %v", err) + } + + if _, err := fmt.Fprintf(ContextGetStdout(ctx), "%s", runner.UUID); err != nil { + panic(err) + } + return nil +} + +func RunGenerateSecret(ctx context.Context, cliCtx *cli.Context) error { + runner := actions_model.ActionRunner{} + if err := runner.GenerateToken(); err != nil { + return err + } + if _, err := fmt.Fprintf(ContextGetStdout(ctx), "%s", runner.Token); err != nil { + panic(err) + } + return nil +} + +func RunGenerateActionsRunnerToken(ctx context.Context, cliCtx *cli.Context) error { + if !ContextGetNoInit(ctx) { + var cancel context.CancelFunc + ctx, cancel = installSignals(ctx) + defer cancel() + } + + setting.MustInstalled() + + scope := cliCtx.String("scope") + + respText, extra := private.GenerateActionsRunnerToken(ctx, scope) + if extra.HasError() { + return handleCliResponseExtra(ctx, extra) + } + if _, err := fmt.Fprintf(ContextGetStdout(ctx), "%s", respText.Text); err != nil { + panic(err) + } + return nil +} + +func prepareWorkPathAndCustomConf(ctx context.Context, action cli.ActionFunc) func(cliCtx *cli.Context) error { + return func(cliCtx *cli.Context) error { + if !ContextGetNoInit(ctx) { + var args setting.ArgWorkPathAndCustomConf + // from children to parent, check the global flags + for _, curCtx := range cliCtx.Lineage() { + if curCtx.IsSet("work-path") && args.WorkPath == "" { + args.WorkPath = curCtx.String("work-path") + } + if curCtx.IsSet("custom-path") && args.CustomPath == "" { + args.CustomPath = curCtx.String("custom-path") + } + if curCtx.IsSet("config") && args.CustomConf == "" { + args.CustomConf = curCtx.String("config") + } + } + setting.InitWorkPathAndCommonConfig(os.Getenv, args) + } + return action(cliCtx) + } +} diff --git a/cmd/forgejo/forgejo.go b/cmd/forgejo/forgejo.go new file mode 100644 index 0000000000..affb39157a --- /dev/null +++ b/cmd/forgejo/forgejo.go @@ -0,0 +1,147 @@ +// Copyright The Forgejo Authors. +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + "io" + "os" + "os/signal" + "syscall" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/setting" + + "github.com/urfave/cli/v2" +) + +type key int + +const ( + noInitKey key = iota + 1 + noExitKey + stdoutKey + stderrKey + stdinKey +) + +func CmdForgejo(ctx context.Context) *cli.Command { + return &cli.Command{ + Name: "forgejo-cli", + Usage: "Forgejo CLI", + Flags: []cli.Flag{}, + Subcommands: []*cli.Command{ + CmdActions(ctx), + }, + } +} + +func ContextSetNoInit(ctx context.Context, value bool) context.Context { + return context.WithValue(ctx, noInitKey, value) +} + +func ContextGetNoInit(ctx context.Context) bool { + value, ok := ctx.Value(noInitKey).(bool) + return ok && value +} + +func ContextSetNoExit(ctx context.Context, value bool) context.Context { + return context.WithValue(ctx, noExitKey, value) +} + +func ContextGetNoExit(ctx context.Context) bool { + value, ok := ctx.Value(noExitKey).(bool) + return ok && value +} + +func ContextSetStderr(ctx context.Context, value io.Writer) context.Context { + return context.WithValue(ctx, stderrKey, value) +} + +func ContextGetStderr(ctx context.Context) io.Writer { + value, ok := ctx.Value(stderrKey).(io.Writer) + if !ok { + return os.Stderr + } + return value +} + +func ContextSetStdout(ctx context.Context, value io.Writer) context.Context { + return context.WithValue(ctx, stdoutKey, value) +} + +func ContextGetStdout(ctx context.Context) io.Writer { + value, ok := ctx.Value(stderrKey).(io.Writer) + if !ok { + return os.Stdout + } + return value +} + +func ContextSetStdin(ctx context.Context, value io.Reader) context.Context { + return context.WithValue(ctx, stdinKey, value) +} + +func ContextGetStdin(ctx context.Context) io.Reader { + value, ok := ctx.Value(stdinKey).(io.Reader) + if !ok { + return os.Stdin + } + return value +} + +// copied from ../cmd.go +func initDB(ctx context.Context) error { + setting.MustInstalled() + setting.LoadDBSetting() + setting.InitSQLLoggersForCli(log.INFO) + + if setting.Database.Type == "" { + log.Fatal(`Database settings are missing from the configuration file: %q. +Ensure you are running in the correct environment or set the correct configuration file with -c. +If this is the intended configuration file complete the [database] section.`, setting.CustomConf) + } + if err := db.InitEngine(ctx); err != nil { + return fmt.Errorf("unable to initialize the database using the configuration in %q. Error: %w", setting.CustomConf, err) + } + return nil +} + +// copied from ../cmd.go +func installSignals(ctx context.Context) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(ctx) + go func() { + // install notify + signalChannel := make(chan os.Signal, 1) + + signal.Notify( + signalChannel, + syscall.SIGINT, + syscall.SIGTERM, + ) + select { + case <-signalChannel: + case <-ctx.Done(): + } + cancel() + signal.Reset() + }() + + return ctx, cancel +} + +func handleCliResponseExtra(ctx context.Context, extra private.ResponseExtra) error { + if false && extra.UserMsg != "" { + if _, err := fmt.Fprintf(ContextGetStdout(ctx), "%s", extra.UserMsg); err != nil { + panic(err) + } + } + if ContextGetNoExit(ctx) { + return extra.Error + } + return cli.Exit(extra.Error, 1) +} diff --git a/cmd/main.go b/cmd/main.go index 02dd660e9e..c0afc138ed 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,10 +4,13 @@ package cmd import ( + "context" "fmt" "os" + "path/filepath" "strings" + "code.gitea.io/gitea/cmd/forgejo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -113,6 +116,36 @@ func prepareWorkPathAndCustomConf(action cli.ActionFunc) func(ctx *cli.Context) } func NewMainApp(version, versionExtra string) *cli.App { + path, err := os.Executable() + if err != nil { + panic(err) + } + executable := filepath.Base(path) + + var subCmdsStandalone []*cli.Command = make([]*cli.Command, 0, 10) + var subCmdWithConfig []*cli.Command = make([]*cli.Command, 0, 10) + + // + // If the executable is forgejo-cli, provide a Forgejo specific CLI + // that is NOT compatible with Gitea. + // + if executable == "forgejo-cli" { + subCmdsStandalone = append(subCmdsStandalone, forgejo.CmdActions(context.Background())) + } else { + // + // Otherwise provide a Gitea compatible CLI which includes Forgejo + // specific additions under the forgejo-cli subcommand. It allows + // admins to migration from Gitea to Forgejo by replacing the gitea + // binary and rename it to forgejo if they want. + // + subCmdsStandalone = append(subCmdsStandalone, forgejo.CmdForgejo(context.Background())) + subCmdWithConfig = append(subCmdWithConfig, CmdActions) + } + + return innerNewMainApp(version, versionExtra, subCmdsStandalone, subCmdWithConfig) +} + +func innerNewMainApp(version, versionExtra string, subCmdsStandaloneArgs, subCmdWithConfigArgs []*cli.Command) *cli.App { app := cli.NewApp() app.Name = "Gitea" app.HelpName = "gitea" @@ -137,15 +170,17 @@ func NewMainApp(version, versionExtra string) *cli.App { CmdMigrateStorage, CmdDumpRepository, CmdRestoreRepository, - CmdActions, } + subCmdWithConfig = append(subCmdWithConfig, subCmdWithConfigArgs...) + // these sub-commands do not need the config file, and they do not depend on any path or environment variable. subCmdStandalone := []*cli.Command{ CmdCert, CmdGenerate, CmdDocs, } + subCmdStandalone = append(subCmdStandalone, subCmdsStandaloneArgs...) app.DefaultCommand = CmdWeb.Name diff --git a/models/actions/forgejo.go b/models/actions/forgejo.go new file mode 100644 index 0000000000..243262facd --- /dev/null +++ b/models/actions/forgejo.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "encoding/hex" + "fmt" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/util" + + gouuid "github.com/google/uuid" +) + +func RegisterRunner(ctx context.Context, ownerID, repoID int64, token string, labels []string, name, version string) (*ActionRunner, error) { + uuid, err := gouuid.FromBytes([]byte(token[:16])) + if err != nil { + return nil, fmt.Errorf("gouuid.FromBytes %v", err) + } + uuidString := uuid.String() + + var runner ActionRunner + + has, err := db.GetEngine(ctx).Where("uuid=?", uuidString).Get(&runner) + if err != nil { + return nil, fmt.Errorf("GetRunner %v", err) + } else if !has { + // + // The runner does not exist yet, create it + // + saltBytes, err := util.CryptoRandomBytes(16) + if err != nil { + return nil, fmt.Errorf("CryptoRandomBytes %v", err) + } + salt := hex.EncodeToString(saltBytes) + + hash := auth_model.HashToken(token, salt) + + runner = ActionRunner{ + UUID: uuidString, + TokenHash: hash, + TokenSalt: salt, + } + + if err := CreateRunner(ctx, &runner); err != nil { + return &runner, fmt.Errorf("can't create new runner %w", err) + } + } + + // + // Update the existing runner + // + name, _ = util.SplitStringAtByteN(name, 255) + + runner.Name = name + runner.OwnerID = ownerID + runner.RepoID = repoID + runner.Version = version + runner.AgentLabels = labels + + if err := UpdateRunner(ctx, &runner, "name", "owner_id", "repo_id", "version", "agent_labels"); err != nil { + return &runner, fmt.Errorf("can't update the runner %+v %w", runner, err) + } + + return &runner, nil +} diff --git a/models/actions/forgejo_test.go b/models/actions/forgejo_test.go new file mode 100644 index 0000000000..a8583c3d00 --- /dev/null +++ b/models/actions/forgejo_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT + +package actions + +import ( + "crypto/subtle" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestActions_RegisterRunner(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + ownerID := int64(0) + repoID := int64(0) + token := "0123456789012345678901234567890123456789" + labels := []string{} + name := "runner" + version := "v1.2.3" + runner, err := RegisterRunner(db.DefaultContext, ownerID, repoID, token, labels, name, version) + assert.NoError(t, err) + assert.EqualValues(t, name, runner.Name) + + assert.EqualValues(t, 1, subtle.ConstantTimeCompare([]byte(runner.TokenHash), []byte(auth_model.HashToken(token, runner.TokenSalt))), "the token cannot be verified with the same method as routers/api/actions/runner/interceptor.go as of 8228751c55d6a4263f0fec2932ca16181c09c97d") +} diff --git a/modules/private/forgejo_actions.go b/modules/private/forgejo_actions.go new file mode 100644 index 0000000000..133d5e253f --- /dev/null +++ b/modules/private/forgejo_actions.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT + +package private + +import ( + "context" + + "code.gitea.io/gitea/modules/setting" +) + +type ActionsRunnerRegisterRequest struct { + Token string + Scope string + Labels []string + Name string + Version string +} + +func ActionsRunnerRegister(ctx context.Context, token, scope string, labels []string, name, version string) (string, ResponseExtra) { + reqURL := setting.LocalURL + "api/internal/actions/register" + + req := newInternalRequest(ctx, reqURL, "POST", ActionsRunnerRegisterRequest{ + Token: token, + Scope: scope, + Labels: labels, + Name: name, + Version: version, + }) + + resp, extra := requestJSONResp(req, &ResponseText{}) + return resp.Text, extra +} diff --git a/routers/private/actions.go b/routers/private/actions.go index 886f23b1c2..1325913e1b 100644 --- a/routers/private/actions.go +++ b/routers/private/actions.go @@ -4,6 +4,7 @@ package private import ( + gocontext "context" "errors" "fmt" "net/http" @@ -64,7 +65,11 @@ func GenerateActionsRunnerToken(ctx *context.PrivateContext) { ctx.PlainText(http.StatusOK, token.Token) } -func parseScope(ctx *context.PrivateContext, scope string) (ownerID, repoID int64, err error) { +func ParseScope(ctx gocontext.Context, scope string) (ownerID, repoID int64, err error) { + return parseScope(ctx, scope) +} + +func parseScope(ctx gocontext.Context, scope string) (ownerID, repoID int64, err error) { ownerID = 0 repoID = 0 if scope == "" { diff --git a/tests/integration/cmd_forgejo_actions_test.go b/tests/integration/cmd_forgejo_actions_test.go new file mode 100644 index 0000000000..44211007f5 --- /dev/null +++ b/tests/integration/cmd_forgejo_actions_test.go @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MIT + +package integration + +import ( + gocontext "context" + "net/url" + "os" + "strings" + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func Test_CmdForgejo_Actions(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + token, err := cmdForgejoCaptureOutput(t, []string{"forgejo", "forgejo-cli", "actions", "generate-runner-token"}) + assert.NoError(t, err) + assert.EqualValues(t, 40, len(token)) + + secret, err := cmdForgejoCaptureOutput(t, []string{"forgejo", "forgejo-cli", "actions", "generate-secret"}) + assert.NoError(t, err) + assert.EqualValues(t, 40, len(secret)) + + _, err = cmdForgejoCaptureOutput(t, []string{"forgejo", "forgejo-cli", "actions", "register"}) + assert.ErrorContains(t, err, "at least one of the --secret") + + for _, testCase := range []struct { + testName string + scope string + secret string + errorMessage string + }{ + { + testName: "bad user", + scope: "baduser", + secret: "0123456789012345678901234567890123456789", + errorMessage: "user does not exist", + }, + { + testName: "bad repo", + scope: "org25/badrepo", + secret: "0123456789012345678901234567890123456789", + errorMessage: "repository does not exist", + }, + { + testName: "secret length != 40", + scope: "org25", + secret: "0123456789", + errorMessage: "40 characters long", + }, + { + testName: "secret is not a hexadecimal string", + scope: "org25", + secret: "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + errorMessage: "must be an hexadecimal string", + }, + } { + t.Run(testCase.testName, func(t *testing.T) { + cmd := []string{"forgejo", "forgejo-cli", "actions", "register", "--secret", testCase.secret, "--scope", testCase.scope} + output, err := cmdForgejoCaptureOutput(t, cmd) + assert.ErrorContains(t, err, testCase.errorMessage) + assert.EqualValues(t, "", output) + }) + } + + secret = "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD" + expecteduuid := "44444444-4444-4444-4444-444444444444" + + for _, testCase := range []struct { + testName string + secretOption func() string + stdin []string + }{ + { + testName: "secret from argument", + secretOption: func() string { + return "--secret=" + secret + }, + }, + { + testName: "secret from stdin", + secretOption: func() string { + return "--secret-stdin" + }, + stdin: []string{secret}, + }, + { + testName: "secret from file", + secretOption: func() string { + secretFile := t.TempDir() + "/secret" + assert.NoError(t, os.WriteFile(secretFile, []byte(secret), 0o644)) + return "--secret-file=" + secretFile + }, + }, + } { + t.Run(testCase.testName, func(t *testing.T) { + cmd := []string{"forgejo", "forgejo-cli", "actions", "register", testCase.secretOption(), "--scope=org26"} + uuid, err := cmdForgejoCaptureOutput(t, cmd, testCase.stdin...) + assert.NoError(t, err) + assert.EqualValues(t, expecteduuid, uuid) + }) + } + + secret = "0123456789012345678901234567890123456789" + expecteduuid = "30313233-3435-3637-3839-303132333435" + + for _, testCase := range []struct { + testName string + scope string + secret string + name string + labels string + version string + uuid string + }{ + { + testName: "org", + scope: "org25", + secret: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + uuid: "41414141-4141-4141-4141-414141414141", + }, + { + testName: "user and repo", + scope: "user2/repo2", + secret: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + uuid: "42424242-4242-4242-4242-424242424242", + }, + { + testName: "labels", + scope: "org25", + name: "runnerName", + labels: "label1,label2,label3", + version: "v1.2.3", + secret: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + uuid: "43434343-4343-4343-4343-434343434343", + }, + { + testName: "insert a runner", + scope: "user10/repo6", + name: "runnerName", + labels: "label1,label2,label3", + version: "v1.2.3", + secret: secret, + uuid: expecteduuid, + }, + { + testName: "update an existing runner", + scope: "user5/repo4", + name: "runnerNameChanged", + labels: "label1,label2,label3,more,label", + version: "v1.2.3-suffix", + secret: secret, + uuid: expecteduuid, + }, + } { + t.Run(testCase.testName, func(t *testing.T) { + cmd := []string{ + "forgejo", "forgejo-cli", "actions", "register", + "--secret", testCase.secret, "--scope", testCase.scope, + } + if testCase.name != "" { + cmd = append(cmd, "--name", testCase.name) + } + if testCase.labels != "" { + cmd = append(cmd, "--labels", testCase.labels) + } + if testCase.version != "" { + cmd = append(cmd, "--version", testCase.version) + } + // + // Run twice to verify it is idempotent + // + for i := 0; i < 2; i++ { + uuid, err := cmdForgejoCaptureOutput(t, cmd) + assert.NoError(t, err) + if assert.EqualValues(t, testCase.uuid, uuid) { + ownerName, repoName, found := strings.Cut(testCase.scope, "/") + action, err := actions_model.GetRunnerByUUID(gocontext.Background(), uuid) + assert.NoError(t, err) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: action.OwnerID}) + assert.Equal(t, ownerName, user.Name, action.OwnerID) + + if found { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: action.RepoID}) + assert.Equal(t, repoName, repo.Name, action.RepoID) + } + if testCase.name != "" { + assert.EqualValues(t, testCase.name, action.Name) + } + if testCase.labels != "" { + labels := strings.Split(testCase.labels, ",") + assert.EqualValues(t, labels, action.AgentLabels) + } + if testCase.version != "" { + assert.EqualValues(t, testCase.version, action.Version) + } + } + } + }) + } + }) +} diff --git a/tests/integration/cmd_forgejo_test.go b/tests/integration/cmd_forgejo_test.go new file mode 100644 index 0000000000..76f5a6fc08 --- /dev/null +++ b/tests/integration/cmd_forgejo_test.go @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "context" + "strings" + "testing" + + "code.gitea.io/gitea/cmd/forgejo" + + "github.com/urfave/cli/v2" +) + +func cmdForgejoCaptureOutput(t *testing.T, args []string, stdin ...string) (string, error) { + buf := new(bytes.Buffer) + + app := cli.NewApp() + app.Writer = buf + app.ErrWriter = buf + ctx := context.Background() + ctx = forgejo.ContextSetNoInit(ctx, true) + ctx = forgejo.ContextSetNoExit(ctx, true) + ctx = forgejo.ContextSetStdout(ctx, buf) + ctx = forgejo.ContextSetStderr(ctx, buf) + if len(stdin) > 0 { + ctx = forgejo.ContextSetStdin(ctx, strings.NewReader(strings.Join(stdin, ""))) + } + app.Commands = []*cli.Command{ + forgejo.CmdForgejo(ctx), + } + err := app.Run(args) + + return buf.String(), err +}