Files
livekit-cli/pkg/bootstrap/bootstrap.go
Tobias Fried 74fd243bf7
Some checks failed
Release to Docker / Generate builds for Docker (push) Has been cancelled
Release / Release (push) Has been cancelled
feat(agents): add streamlined lk agent init command (#696)
* feat(agents): add streamlined lk agent init command

* chore(agents): support agent-starter-node init option

* chore: bump version
2025-10-01 09:44:44 -06:00

354 lines
9.2 KiB
Go

// Copyright 2024 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// This package defines the configuration file associated with LiveKit
// templates, which include instructions for package setup and development in
// both Playground mode and local dev.
package bootstrap
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"os/exec"
"path"
"runtime"
"strings"
"time"
"github.com/go-task/task/v3"
"github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/taskfile/ast"
"github.com/joho/godotenv"
"gopkg.in/yaml.v3"
authutil "github.com/livekit/livekit-cli/v2/pkg/auth"
)
const (
TaskFile = "taskfile.yaml"
TemplateIndexFile = "templates.yaml"
TemplateIndexURL = "https://raw.githubusercontent.com/livekit-examples/index/main"
TemplateBaseURL = "https://github.com/livekit-examples"
SandboxDashboardURL = "https://cloud.livekit.io/projects/p_/sandbox"
SandboxTemplateEndpoint = "/api/sandbox/template"
SandboxCreateEndpoint = "/api/sandbox/create"
)
type KnownTask string
const (
TaskPostCreate KnownTask = "post_create"
TaskInstall KnownTask = "install"
TaskDev KnownTask = "dev"
)
// Files to remove after cloning a template
var templateIgnoreFiles = []string{
".git",
".task",
"renovate.json",
"taskfile.yaml",
"TEMPLATE.md",
"LICENSE",
"LICENSE.md",
"NOTICE",
}
type Template struct {
Name string `yaml:"name" json:"name"`
Desc string `yaml:"desc" json:"description,omitempty"`
URL string `yaml:"url" json:"url,omitempty"`
Docs string `yaml:"docs" json:"docs_url,omitempty"`
Image string `yaml:"image" json:"image_ref,omitempty"`
Tags []string `yaml:"tags" json:"tags,omitempty"`
Attrs map[string]string `yaml:"attrs" json:"attrs,omitempty"`
Requires []string `yaml:"requires" json:"requires,omitempty"`
IsSandbox bool `yaml:"is_sandbox" json:"is_sandbox,omitempty"`
IsHidden bool `yaml:"is_hidden" json:"is_hidden,omitempty"`
}
type SandboxDetails struct {
Name string `json:"name"`
Template Template `json:"template"`
ChildTemplates []Template `json:"childTemplates"`
}
func FetchTemplates(ctx context.Context) ([]Template, error) {
resp, err := http.Get(TemplateIndexURL + "/" + TemplateIndexFile)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var templates []Template
if err := yaml.NewDecoder(resp.Body).Decode(&templates); err != nil {
return nil, err
}
return templates, nil
}
func CreateSandbox(ctx context.Context, agentName, templateURL, token, serverURL string) (string, error) {
type createSandboxRequest struct {
TemplateURL string `json:"template_url"`
AgentName string `json:"agent_name,omitempty"`
}
type createSandboxResponse struct {
SandboxID string `json:"sandbox_id"`
}
body := createSandboxRequest{
TemplateURL: templateURL,
AgentName: agentName,
}
buf := new(bytes.Buffer)
if err := json.NewEncoder(buf).Encode(body); err != nil {
panic(err)
}
req, err := http.NewRequestWithContext(ctx, "POST", serverURL+SandboxCreateEndpoint, buf)
req.Header = authutil.NewHeaderWithToken(token)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
if resp.StatusCode != 200 {
return "", errors.New(resp.Status)
}
var response createSandboxResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return "", err
}
return response.SandboxID, nil
}
func FetchSandboxDetails(ctx context.Context, sid, token, serverURL string) (*SandboxDetails, error) {
req, err := http.NewRequestWithContext(ctx, "GET", serverURL+SandboxTemplateEndpoint, nil)
req.Header = authutil.NewHeaderWithToken(token)
query := req.URL.Query()
query.Add("id", sid)
req.URL.RawQuery = query.Encode()
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == 404 {
return nil, fmt.Errorf("sandbox not found: %s", sid)
}
if resp.StatusCode != 200 {
return nil, errors.New(resp.Status)
}
var details SandboxDetails
if err := json.NewDecoder(resp.Body).Decode(&details); err != nil {
return nil, err
}
return &details, nil
}
func ParseTaskfile(rootPath string) (*ast.Taskfile, error) {
os.Setenv("TASK_X_REMOTE_TASKFILES", "1")
experiments.Parse(rootPath)
taskfilePath := path.Join(rootPath, TaskFile)
// taskfile.yaml is optional
if _, err := os.Stat(taskfilePath); err != nil && errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
file, err := os.Open(taskfilePath)
if err != nil {
return nil, err
}
defer file.Close()
tf := &ast.Taskfile{}
if err := yaml.NewDecoder(file).Decode(tf); err != nil {
return nil, err
}
return tf, nil
}
func NewTaskExecutor(dir string, verbose bool) *task.Executor {
var o io.Writer = os.Stdout
var e io.Writer = os.Stderr
return &task.Executor{
Dir: dir,
Force: false,
ForceAll: false,
Insecure: false,
Download: true,
Offline: false,
Watch: false,
Verbose: false,
Silent: true,
Dry: false,
Summary: false,
Parallel: false,
Color: true,
Timeout: time.Second * 10,
AssumeYes: true,
Stdin: os.Stdin,
Stdout: o,
Stderr: e,
}
}
func NewTask(ctx context.Context, tf *ast.Taskfile, dir, taskName string, verbose bool) (func() error, error) {
exe := NewTaskExecutor(dir, verbose)
err := exe.Setup()
if err != nil {
return nil, err
}
return NewTaskWithExecutor(ctx, exe, taskName, verbose)
}
func NewTaskWithExecutor(ctx context.Context, exe *task.Executor, taskName string, verbose bool) (func() error, error) {
_, ok := exe.Taskfile.Tasks.Get(taskName)
if !ok {
return nil, fmt.Errorf("task %q not found", taskName)
}
call := &task.Call{
Task: taskName,
Silent: !verbose,
}
return func() error {
return exe.Run(ctx, call)
}, nil
}
type PromptFunc func(key string, value string) (string, error)
// Read .env.example file if present in rootDir, replacing all `substitutions`,
// prompting for others, and returning the result as a map.
func InstantiateDotEnv(ctx context.Context, rootDir string, exampleFilePath string, substitutions map[string]string, verbose bool, prompt PromptFunc) (map[string]string, error) {
promptedVars := map[string]string{}
envExamplePath := path.Join(rootDir, exampleFilePath)
stat, err := os.Stat(envExamplePath)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
} else if stat != nil {
if stat.IsDir() {
return nil, errors.New(".env.example file is a directory")
}
envMap, err := godotenv.Read(envExamplePath)
if err != nil {
return nil, err
}
for key, oldValue := range envMap {
// if key is a substitution, replace it
if value, ok := substitutions[key]; ok {
envMap[key] = value
// if key was already promped, use that value
} else if alreadyPromptedValue, ok := promptedVars[key]; ok {
envMap[key] = alreadyPromptedValue
} else {
// prompt for value
newValue, err := prompt(key, oldValue)
if err != nil {
return nil, err
}
envMap[key] = newValue
promptedVars[key] = newValue
}
}
return envMap, nil
} else {
return substitutions, nil
}
}
func PrintDotEnv(envMap map[string]string) error {
envContents, err := godotenv.Marshal(envMap)
if err != nil {
return err
}
_, err = fmt.Println(envContents)
return err
}
func WriteDotEnv(rootDir string, filePath string, envMap map[string]string) error {
envContents, err := godotenv.Marshal(envMap)
if err != nil {
return err
}
envLocalPath := path.Join(rootDir, filePath)
return os.WriteFile(envLocalPath, []byte(envContents+"\n"), 0700)
}
func CloneTemplate(url, dir string) (string, string, error) {
var stdout = strings.Builder{}
var stderr = strings.Builder{}
cmd := exec.Command("git", "clone", "--depth=1", url, dir)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
return stdout.String(), stderr.String(), err
}
func CleanupTemplate(dir string) error {
// Remove files that are only needed for template instantiation
for _, cleanup := range templateIgnoreFiles {
if err := os.RemoveAll(path.Join(dir, cleanup)); err != nil {
return err
}
}
return nil
}
// Determine if `cmd` is a binary in PATH or a known alias
func CommandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return (err == nil || CommandIsAlias(cmd))
}
// Determine if `cmd` is a known alias
func CommandIsAlias(cmd string) bool {
if runtime.GOOS == "windows" {
return false
}
out, err := exec.Command("alias", cmd).Output()
if err != nil {
return false
}
output := strings.TrimSpace(string(out))
return strings.HasPrefix(output, cmd+"=")
}