github.com/qiniu/go-sdk/v7@v7.27.0/examples/sandbox_resources/main.go (about)

     1  package main
     2  
     3  import (
     4  	"bufio"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"log"
     9  	"os"
    10  	"path"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/qiniu/go-sdk/v7/auth"
    15  	"github.com/qiniu/go-sdk/v7/sandbox"
    16  )
    17  
    18  func main() {
    19  	loadEnvFileIfExists("examples/sandbox_resources/.env", ".env")
    20  
    21  	// 确保设置了环境变量 QINIU_API_KEY
    22  	apiKey := os.Getenv("QINIU_API_KEY")
    23  	if apiKey == "" {
    24  		log.Fatal("请设置 QINIU_API_KEY 环境变量")
    25  	}
    26  
    27  	apiURL := strings.TrimSpace(os.Getenv("QINIU_SANDBOX_API_URL"))
    28  
    29  	ctx := context.Background()
    30  	gitConfig := loadGitResourceConfig()
    31  	kodoConfig, err := loadKodoResourceConfig()
    32  	if err != nil {
    33  		log.Fatal(err)
    34  	}
    35  
    36  	if !gitConfig.Enabled() && !kodoConfig.Enabled() {
    37  		log.Fatal("请设置 GITHUB_TOKEN 和 QINIU_SANDBOX_GIT_REPO_URL,或设置 QINIU_SANDBOX_KODO_BUCKET,以启用至少一种资源")
    38  	}
    39  
    40  	var failures []error
    41  	if gitConfig.Enabled() {
    42  		client, err := sandbox.NewClient(&sandbox.Config{
    43  			APIKey:   apiKey,
    44  			Endpoint: apiURL,
    45  		})
    46  		if err != nil {
    47  			failures = append(failures, fmt.Errorf("create Git resource client: %w", err))
    48  		} else if err := runGitResourceExample(ctx, client, gitConfig); err != nil {
    49  			failures = append(failures, fmt.Errorf("Git resource example: %w", err))
    50  			log.Printf("Git resource example failed: %v", err)
    51  		}
    52  	}
    53  
    54  	if kodoConfig.Enabled() {
    55  		if kodoConfig.AccessKey == "" || kodoConfig.SecretKey == "" {
    56  			failures = append(failures, errors.New("启用 Kodo 资源时请设置 QINIU_ACCESS_KEY 和 QINIU_SECRET_KEY 环境变量"))
    57  		} else {
    58  			client, err := sandbox.NewClient(&sandbox.Config{
    59  				APIKey:      apiKey,
    60  				Credentials: auth.New(kodoConfig.AccessKey, kodoConfig.SecretKey),
    61  				Endpoint:    apiURL,
    62  			})
    63  			if err != nil {
    64  				failures = append(failures, fmt.Errorf("create Kodo resource client: %w", err))
    65  			} else if err := runKodoResourceExample(ctx, client, kodoConfig); err != nil {
    66  				failures = append(failures, fmt.Errorf("Kodo resource example: %w", err))
    67  				log.Printf("Kodo resource example failed: %v", err)
    68  			}
    69  		}
    70  	}
    71  
    72  	if len(failures) > 0 {
    73  		log.Fatal(errors.Join(failures...))
    74  	}
    75  }
    76  
    77  type gitResourceConfig struct {
    78  	RepoURL       string
    79  	Token         string
    80  	MountPath     string
    81  	PushTest      bool
    82  	TestFile      string
    83  	CommitName    string
    84  	CommitEmail   string
    85  	CommitMessage string
    86  	PushBranch    string
    87  }
    88  
    89  func loadGitResourceConfig() gitResourceConfig {
    90  	return gitResourceConfig{
    91  		RepoURL:       os.Getenv("QINIU_SANDBOX_GIT_REPO_URL"),
    92  		Token:         os.Getenv("GITHUB_TOKEN"),
    93  		MountPath:     "/workspace/repo",
    94  		PushTest:      envBool("QINIU_SANDBOX_GIT_PUSH_TEST", false),
    95  		TestFile:      envDefault("QINIU_SANDBOX_GIT_TEST_FILE", "sandbox-resource-write-test.txt"),
    96  		CommitName:    envDefault("QINIU_SANDBOX_GIT_COMMIT_NAME", "Sandbox Resource Demo"),
    97  		CommitEmail:   envDefault("QINIU_SANDBOX_GIT_COMMIT_EMAIL", "sandbox-resource-demo@example.com"),
    98  		CommitMessage: envDefault("QINIU_SANDBOX_GIT_COMMIT_MESSAGE", "test: update sandbox resource write file"),
    99  		PushBranch:    os.Getenv("QINIU_SANDBOX_GIT_PUSH_BRANCH"),
   100  	}
   101  }
   102  
   103  func (c gitResourceConfig) Enabled() bool {
   104  	return c.Token != "" && c.RepoURL != ""
   105  }
   106  
   107  func runGitResourceExample(ctx context.Context, client *sandbox.Client, cfg gitResourceConfig) error {
   108  	resource := sandbox.GitRepositoryResource{
   109  		Type:               sandbox.GitRepositoryTypeGithub,
   110  		URL:                cfg.RepoURL,
   111  		MountPath:          cfg.MountPath,
   112  		AuthorizationToken: &cfg.Token,
   113  	}
   114  	resources := []sandbox.SandboxResourceSpec{{GitRepository: &resource}}
   115  
   116  	log.Println("Creating sandbox with Git repository resource...")
   117  	sb, info, err := client.CreateAndWait(ctx, sandbox.CreateParams{
   118  		TemplateID: "base",
   119  		Resources:  &resources,
   120  	})
   121  	if err != nil {
   122  		return err
   123  	}
   124  	defer func() {
   125  		log.Println("Killing Git resource sandbox...")
   126  		cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
   127  		defer cancel()
   128  		_ = sb.Kill(cleanupCtx)
   129  	}()
   130  
   131  	log.Printf("Git resource sandbox created successfully! ID: %s, State: %s\n", sb.ID(), info.State)
   132  	if err := runAndLog(ctx, sb, "ls -la "+shellQuote(cfg.MountPath)+" | head -20"); err != nil {
   133  		return err
   134  	}
   135  
   136  	var pushURL string
   137  	if cfg.PushTest {
   138  		pushURL, err = githubTokenPushURL(cfg.RepoURL)
   139  		if err != nil {
   140  			return err
   141  		}
   142  		if err := runAndLog(ctx, sb, gitSyncBranchCommand(cfg.MountPath, cfg.PushBranch, pushURL), sandbox.WithTimeout(2*time.Minute), sandbox.WithEnvs(map[string]string{
   143  			"GITHUB_TOKEN":        cfg.Token,
   144  			"GIT_TERMINAL_PROMPT": "0",
   145  		})); err != nil {
   146  			return err
   147  		}
   148  	}
   149  
   150  	writeContent := "sandbox resource write test\n" +
   151  		"time: " + time.Now().UTC().Format(time.RFC3339Nano) + "\n" +
   152  		"sandbox: " + sb.ID() + "\n"
   153  	gitFilePath := path.Join(cfg.MountPath, cfg.TestFile)
   154  	if err := runAndLog(ctx, sb, writeFileCommand(gitFilePath, writeContent+"resource: git\n")); err != nil {
   155  		return err
   156  	}
   157  	if err := runAndLog(ctx, sb, "git -C "+shellQuote(cfg.MountPath)+" status --short "+shellQuote(cfg.TestFile)); err != nil {
   158  		return err
   159  	}
   160  
   161  	if cfg.PushTest {
   162  		if err := runAndLog(ctx, sb, gitCommitAndPushCommand(cfg.MountPath, cfg.TestFile, cfg.CommitName, cfg.CommitEmail, cfg.CommitMessage, cfg.PushBranch), sandbox.WithTimeout(2*time.Minute), sandbox.WithEnvs(map[string]string{
   163  			"GITHUB_TOKEN":        cfg.Token,
   164  			"GIT_TERMINAL_PROMPT": "0",
   165  		})); err != nil {
   166  			return err
   167  		}
   168  	}
   169  	return nil
   170  }
   171  
   172  type kodoResourceConfig struct {
   173  	Bucket    string
   174  	MountPath string
   175  	Prefix    string
   176  	ReadOnly  *bool
   177  	AccessKey string
   178  	SecretKey string
   179  	WriteTest bool
   180  	TestFile  string
   181  }
   182  
   183  func loadKodoResourceConfig() (kodoResourceConfig, error) {
   184  	readOnly, err := optionalEnvBool("QINIU_SANDBOX_KODO_READ_ONLY")
   185  	if err != nil {
   186  		return kodoResourceConfig{}, err
   187  	}
   188  	return kodoResourceConfig{
   189  		Bucket:    os.Getenv("QINIU_SANDBOX_KODO_BUCKET"),
   190  		MountPath: envDefault("QINIU_SANDBOX_KODO_MOUNT_PATH", "/workspace/kodo"),
   191  		Prefix:    os.Getenv("QINIU_SANDBOX_KODO_PREFIX"),
   192  		ReadOnly:  readOnly,
   193  		AccessKey: os.Getenv("QINIU_ACCESS_KEY"),
   194  		SecretKey: os.Getenv("QINIU_SECRET_KEY"),
   195  		WriteTest: envBool("QINIU_SANDBOX_KODO_WRITE_TEST", true),
   196  		TestFile:  envDefault("QINIU_SANDBOX_KODO_TEST_FILE", "sandbox-resource-write-test.txt"),
   197  	}, nil
   198  }
   199  
   200  func (c kodoResourceConfig) Enabled() bool {
   201  	return c.Bucket != ""
   202  }
   203  
   204  func runKodoResourceExample(ctx context.Context, client *sandbox.Client, cfg kodoResourceConfig) error {
   205  	resource := sandbox.KodoResource{
   206  		Bucket:    cfg.Bucket,
   207  		MountPath: cfg.MountPath,
   208  	}
   209  	if cfg.Prefix != "" {
   210  		resource.Prefix = &cfg.Prefix
   211  	}
   212  	if cfg.ReadOnly != nil {
   213  		resource.ReadOnly = cfg.ReadOnly
   214  	}
   215  	resources := []sandbox.SandboxResourceSpec{{Kodo: &resource}}
   216  
   217  	log.Println("Creating sandbox with Kodo bucket resource...")
   218  	sb, info, err := client.CreateAndWait(ctx, sandbox.CreateParams{
   219  		TemplateID: "base",
   220  		Resources:  &resources,
   221  	})
   222  	if err != nil {
   223  		return err
   224  	}
   225  	defer func() {
   226  		log.Println("Killing Kodo resource sandbox...")
   227  		cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
   228  		defer cancel()
   229  		_ = sb.Kill(cleanupCtx)
   230  	}()
   231  
   232  	log.Printf("Kodo resource sandbox created successfully! ID: %s, State: %s\n", sb.ID(), info.State)
   233  	if err := runAndLog(ctx, sb, "ls -la "+shellQuote(cfg.MountPath)+" | head -20"); err != nil {
   234  		return err
   235  	}
   236  
   237  	if cfg.WriteTest {
   238  		if cfg.ReadOnlyEnabled() {
   239  			log.Println("Skipping Kodo write test because QINIU_SANDBOX_KODO_READ_ONLY=true")
   240  			return nil
   241  		}
   242  		writeContent := "sandbox resource write test\n" +
   243  			"time: " + time.Now().UTC().Format(time.RFC3339Nano) + "\n" +
   244  			"sandbox: " + sb.ID() + "\n" +
   245  			"resource: kodo\n"
   246  		if err := runAndLog(ctx, sb, writeFileCommand(path.Join(cfg.MountPath, cfg.TestFile), writeContent)); err != nil {
   247  			return err
   248  		}
   249  	}
   250  	return nil
   251  }
   252  
   253  func (c kodoResourceConfig) ReadOnlyEnabled() bool {
   254  	return c.ReadOnly != nil && *c.ReadOnly
   255  }
   256  
   257  func loadEnvFileIfExists(paths ...string) {
   258  	for _, p := range paths {
   259  		file, err := os.Open(p)
   260  		if err != nil {
   261  			continue
   262  		}
   263  
   264  		scanner := bufio.NewScanner(file)
   265  		for scanner.Scan() {
   266  			line := strings.TrimSpace(scanner.Text())
   267  			if line == "" || strings.HasPrefix(line, "#") {
   268  				continue
   269  			}
   270  			key, value, ok := strings.Cut(line, "=")
   271  			if !ok {
   272  				continue
   273  			}
   274  			key = strings.TrimSpace(key)
   275  			if key == "" {
   276  				continue
   277  			}
   278  			value = strings.TrimSpace(value)
   279  			value = strings.Trim(value, `"'`)
   280  			value = strings.TrimSpace(value)
   281  			if _, exists := os.LookupEnv(key); !exists {
   282  				_ = os.Setenv(key, value)
   283  			}
   284  		}
   285  		if err := file.Close(); err != nil {
   286  			log.Fatalf("Failed to close .env file %s: %v", p, err)
   287  		}
   288  		if err := scanner.Err(); err != nil {
   289  			log.Fatalf("Failed to read .env file %s: %v", p, err)
   290  		}
   291  		log.Printf("Loaded environment from %s", p)
   292  		return
   293  	}
   294  }
   295  
   296  func envDefault(key, fallback string) string {
   297  	value := os.Getenv(key)
   298  	if value == "" {
   299  		return fallback
   300  	}
   301  	return value
   302  }
   303  
   304  func envBool(key string, fallback bool) bool {
   305  	value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
   306  	switch value {
   307  	case "":
   308  		return fallback
   309  	case "1", "true", "yes", "on":
   310  		return true
   311  	default:
   312  		return false
   313  	}
   314  }
   315  
   316  func optionalEnvBool(key string) (*bool, error) {
   317  	value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
   318  	switch value {
   319  	case "":
   320  		return nil, nil
   321  	case "1", "true", "yes", "on":
   322  		v := true
   323  		return &v, nil
   324  	case "0", "false", "no", "off":
   325  		v := false
   326  		return &v, nil
   327  	default:
   328  		return nil, fmt.Errorf("%s must be one of 1, true, yes, on, 0, false, no, or off", key)
   329  	}
   330  }
   331  
   332  func runAndLog(ctx context.Context, sb *sandbox.Sandbox, cmd string, opts ...sandbox.CommandOption) error {
   333  	log.Printf("Executing command in sandbox:\n$ %s\n", cmd)
   334  
   335  	result, err := sb.Commands().Run(ctx, cmd, opts...)
   336  	if err != nil {
   337  		return fmt.Errorf("run command: %w", err)
   338  	}
   339  
   340  	log.Printf("ExitCode: %d\n", result.ExitCode)
   341  	if result.Stdout != "" {
   342  		log.Printf("Stdout:\n%s\n", result.Stdout)
   343  	}
   344  	if result.Stderr != "" {
   345  		log.Printf("Stderr:\n%s\n", result.Stderr)
   346  	}
   347  	if result.ExitCode != 0 {
   348  		return fmt.Errorf("command failed with exit code %d", result.ExitCode)
   349  	}
   350  	return nil
   351  }
   352  
   353  func githubTokenPushURL(repoURL string) (string, error) {
   354  	repoURL = strings.TrimSpace(repoURL)
   355  	repoURL = strings.TrimSuffix(repoURL, "/")
   356  	switch {
   357  	case strings.HasPrefix(repoURL, "https://"):
   358  		return "https://x-access-token:${GITHUB_TOKEN}@" + strings.TrimPrefix(repoURL, "https://"), nil
   359  	case strings.HasPrefix(repoURL, "http://"):
   360  		return "https://x-access-token:${GITHUB_TOKEN}@" + strings.TrimPrefix(repoURL, "http://"), nil
   361  	case strings.HasPrefix(repoURL, "git@github.com:"):
   362  		return "https://x-access-token:${GITHUB_TOKEN}@github.com/" + strings.TrimPrefix(repoURL, "git@github.com:"), nil
   363  	default:
   364  		return "", fmt.Errorf("unsupported GitHub repository URL: %s", repoURL)
   365  	}
   366  }
   367  
   368  func gitSyncBranchCommand(repoPath, branch, pushURL string) string {
   369  	branchExpr := "branch=" + shellQuote(branch)
   370  	if branch == "" {
   371  		branchExpr = "branch=$(git -C " + shellQuote(repoPath) + " rev-parse --abbrev-ref HEAD)"
   372  	}
   373  	return strings.Join([]string{
   374  		"set -e",
   375  		branchExpr,
   376  		"test \"$branch\" != HEAD",
   377  		"git -C " + shellQuote(repoPath) + " remote set-url origin " + shellDoubleQuote(pushURL),
   378  		"git -C " + shellQuote(repoPath) + " fetch origin \"$branch\"",
   379  		"git -C " + shellQuote(repoPath) + " checkout \"$branch\"",
   380  		"git -C " + shellQuote(repoPath) + " reset --hard \"origin/$branch\"",
   381  	}, " && ")
   382  }
   383  
   384  func gitCommitAndPushCommand(repoPath, filePath, name, email, message, branch string) string {
   385  	branchExpr := "branch=" + shellQuote(branch)
   386  	if branch == "" {
   387  		branchExpr = "branch=$(git -C " + shellQuote(repoPath) + " rev-parse --abbrev-ref HEAD)"
   388  	}
   389  	return strings.Join([]string{
   390  		"set -e",
   391  		branchExpr,
   392  		"test \"$branch\" != HEAD",
   393  		"git -C " + shellQuote(repoPath) + " config user.name " + shellQuote(name),
   394  		"git -C " + shellQuote(repoPath) + " config user.email " + shellQuote(email),
   395  		"git -C " + shellQuote(repoPath) + " add " + shellQuote(filePath),
   396  		"git -C " + shellQuote(repoPath) + " commit -m " + shellQuote(message),
   397  		"for attempt in 1 2 3; do if git -C " + shellQuote(repoPath) + " push origin HEAD:\"$branch\"; then break; fi; if [ \"$attempt\" = 3 ]; then exit 1; fi; sleep $((attempt * 2)); done",
   398  	}, " && ")
   399  }
   400  
   401  func writeFileCommand(filePath, content string) string {
   402  	dir := path.Dir(filePath)
   403  	return strings.Join([]string{
   404  		"mkdir -p " + shellQuote(dir),
   405  		"printf %s " + shellQuote(content) + " > " + shellQuote(filePath),
   406  		"ls -la " + shellQuote(filePath),
   407  		"sed -n '1,20p' " + shellQuote(filePath),
   408  	}, " && ")
   409  }
   410  
   411  func shellQuote(s string) string {
   412  	return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
   413  }
   414  
   415  func shellDoubleQuote(s string) string {
   416  	const tokenPlaceholder = "__GITHUB_TOKEN_PLACEHOLDER__"
   417  	s = strings.ReplaceAll(s, "${GITHUB_TOKEN}", tokenPlaceholder)
   418  	replacer := strings.NewReplacer(`\`, `\\`, `"`, `\"`, `$`, `\$`, "`", "\\`")
   419  	return `"` + strings.ReplaceAll(replacer.Replace(s), tokenPlaceholder, "${GITHUB_TOKEN}") + `"`
   420  }