diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index df61c09..ee102d4 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -101,7 +101,17 @@ jobs: runner-path: ${{ github.workspace }}/actrun graph-file: build-test-publish.act inputs: ${{ toJson(inputs) }} - secrets: ${{ toJson(secrets) }} + secrets: >- + { + "APPLE_P12_CERTIFICATE_PASSWORD": "${{ secrets.APPLE_P12_CERTIFICATE_PASSWORD }}", + "APPLE_P12_CERTIFICATE_BASE64": "${{ secrets.APPLE_P12_CERTIFICATE_BASE64 }}", + "TESTE2E_DO_S3_ACCESS_KEY": "${{ secrets.TESTE2E_DO_S3_ACCESS_KEY }}", + "TESTE2E_DO_S3_SECRET_KEY": "${{ secrets.TESTE2E_DO_S3_SECRET_KEY }}", + "TESTE2E_AWS_S3_ACCESS_KEY": "${{ secrets.TESTE2E_AWS_S3_ACCESS_KEY }}", + "TESTE2E_AWS_S3_SECRET_KEY": "${{ secrets.TESTE2E_AWS_S3_SECRET_KEY }}", + "PUBLISH_S3_SECRET_KEY": "${{ secrets.PUBLISH_S3_SECRET_KEY }}", + "PUBLISH_S3_ACCESS_KEY": "${{ secrets.PUBLISH_S3_ACCESS_KEY }}" + } matrix: ${{ toJson(matrix) }} docker-manifest: diff --git a/agent/client.go b/agent/client.go index 64cf4a6..24253b4 100644 --- a/agent/client.go +++ b/agent/client.go @@ -12,6 +12,7 @@ import ( type Client struct { serverURL string token string + uuid string httpClient *http.Client } @@ -25,6 +26,10 @@ func NewClient(serverURL, token string) *Client { } } +func (c *Client) SetUUID(uuid string) { + c.uuid = uuid +} + func (c *Client) doRequest(method, path string, body interface{}) (*http.Response, error) { var bodyReader io.Reader if body != nil { @@ -40,6 +45,9 @@ func (c *Client) doRequest(method, path string, body interface{}) (*http.Respons return nil, err } req.Header.Set("Authorization", "Bearer "+c.token) + if c.uuid != "" { + req.Header.Set("X-Agent-UUID", c.uuid) + } if body != nil { req.Header.Set("Content-Type", "application/json") } diff --git a/agent/flock_unix.go b/agent/flock_unix.go new file mode 100644 index 0000000..2713af6 --- /dev/null +++ b/agent/flock_unix.go @@ -0,0 +1,16 @@ +//go:build !windows + +package agent + +import ( + "os" + "syscall" +) + +func lockFileExclusive(f *os.File) error { + return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) +} + +func unlockFile(f *os.File) { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} diff --git a/agent/flock_windows.go b/agent/flock_windows.go new file mode 100644 index 0000000..e967bd0 --- /dev/null +++ b/agent/flock_windows.go @@ -0,0 +1,45 @@ +//go:build windows + +package agent + +import ( + "os" + "syscall" + "unsafe" +) + +var ( + modkernel32 = syscall.NewLazyDLL("kernel32.dll") + procLockFileEx = modkernel32.NewProc("LockFileEx") + procUnlockFileEx = modkernel32.NewProc("UnlockFileEx") +) + +const ( + lockfileExclusiveLock = 0x00000002 + lockfileFailImmediately = 0x00000001 +) + +func lockFileExclusive(f *os.File) error { + var overlapped syscall.Overlapped + r1, _, err := procLockFileEx.Call( + uintptr(f.Fd()), + uintptr(lockfileExclusiveLock|lockfileFailImmediately), + 0, + 1, 0, + uintptr(unsafe.Pointer(&overlapped)), + ) + if r1 == 0 { + return err + } + return nil +} + +func unlockFile(f *os.File) { + var overlapped syscall.Overlapped + procUnlockFileEx.Call( + uintptr(f.Fd()), + 0, + 1, 0, + uintptr(unsafe.Pointer(&overlapped)), + ) +} diff --git a/agent/vcs/p4.go b/agent/vcs/p4.go index 587cac7..a76c37f 100644 --- a/agent/vcs/p4.go +++ b/agent/vcs/p4.go @@ -93,7 +93,7 @@ func (p *P4Provider) Checkout(ctx context.Context, url, ref, pipeline, destDir s return CheckoutResult{}, fmt.Errorf("p4 sync pipeline file failed: %w", err) } - return CheckoutResult{Dir: root, Persistent: true}, nil + return CheckoutResult{Dir: root, Persistent: true, P4Client: p.clientName}, nil } // Create temporary workspace @@ -132,7 +132,7 @@ func (p *P4Provider) Checkout(ctx context.Context, url, ref, pipeline, destDir s return CheckoutResult{}, fmt.Errorf("p4 sync pipeline file failed: %w", err) } - return CheckoutResult{Dir: absDir}, nil + return CheckoutResult{Dir: absDir, Persistent: true, P4Client: p.clientName}, nil } func (p *P4Provider) Cleanup(ctx context.Context) error { diff --git a/agent/vcs/vcs.go b/agent/vcs/vcs.go index 9be4289..419a1ce 100644 --- a/agent/vcs/vcs.go +++ b/agent/vcs/vcs.go @@ -33,6 +33,10 @@ type CheckoutResult struct { Persistent bool // SHA is the resolved commit SHA (or changelist number for P4) after checkout. SHA string + // P4Client is the Perforce workspace name created or reused during checkout. + // The worker should set P4CLIENT in the subprocess environment so that + // p4 commands within the graph can operate on the same workspace. + P4Client string } // Provider handles VCS checkout operations. diff --git a/agent/worker.go b/agent/worker.go index 58f4b21..c5226ef 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -26,6 +26,7 @@ type Worker struct { vcsOpts vcs.Options pollInterval time.Duration uuid string + slotCleanup func() log *logrus.Entry metricsMu sync.Mutex @@ -33,45 +34,87 @@ type Worker struct { } func NewWorker(client *Client, docker DockerConfig, vcsOpts vcs.Options) *Worker { + uuid, cleanup := acquireAgentSlot() + client.SetUUID(uuid) return &Worker{ client: client, docker: docker, vcsOpts: vcsOpts, pollInterval: 1 * time.Second, - uuid: loadOrGenerateUUID(), + uuid: uuid, + slotCleanup: cleanup, log: logrus.WithField("component", "agent"), } } -// uuidFilePath returns the path to the persistent UUID file in the user's config directory. -func uuidFilePath() string { +// agentSlotDir returns the directory for agent UUID slot files. +func agentSlotDir() string { dir, err := os.UserConfigDir() if err != nil { dir = os.TempDir() } - return filepath.Join(dir, "actionforge", "agent-uuid") + return filepath.Join(dir, "actionforge") } -// loadOrGenerateUUID loads a persistent UUID from disk, or generates and saves a new one. -func loadOrGenerateUUID() string { - path := uuidFilePath() - if data, err := os.ReadFile(path); err == nil { - if id := strings.TrimSpace(string(data)); len(id) == 36 { - return id +// acquireAgentSlot finds and locks the lowest available agent slot. +// Each slot has a persistent UUID file and a lock file. When the process +// exits, the lock is released so the next process can reuse that slot +// (and its UUID/metrics history). +// Returns the UUID and a cleanup function that releases the lock. +func acquireAgentSlot() (string, func()) { + dir := agentSlotDir() + _ = os.MkdirAll(dir, 0700) + + const maxSlots = 256 + for i := 0; i < maxSlots; i++ { + lockPath := filepath.Join(dir, fmt.Sprintf("agent-%d.lock", i)) + uuidPath := filepath.Join(dir, fmt.Sprintf("agent-%d.uuid", i)) + + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + continue + } + + if err := lockFileExclusive(lockFile); err != nil { + if cerr := lockFile.Close(); cerr != nil { + logrus.WithError(cerr).Warn("failed to close lock file") + } + continue } + + // Slot acquired — read or generate UUID + uuid := "" + if data, err := os.ReadFile(uuidPath); err == nil { + if id := strings.TrimSpace(string(data)); len(id) == 36 { + uuid = id + } + } + if uuid == "" { + var buf [16]byte + _, _ = rand.Read(buf[:]) + buf[6] = (buf[6] & 0x0f) | 0x40 // version 4 + buf[8] = (buf[8] & 0x3f) | 0x80 // variant 1 + uuid = fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]) + _ = os.WriteFile(uuidPath, []byte(uuid+"\n"), 0600) + } + + cleanup := func() { + unlockFile(lockFile) + if cerr := lockFile.Close(); cerr != nil { + logrus.WithError(cerr).Warn("failed to close lock file") + } + } + return uuid, cleanup } - // Generate UUID v4 + // Fallback: all slots taken, generate ephemeral UUID with no lock var buf [16]byte _, _ = rand.Read(buf[:]) - buf[6] = (buf[6] & 0x0f) | 0x40 // version 4 - buf[8] = (buf[8] & 0x3f) | 0x80 // variant 1 - id := fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", - buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]) - - _ = os.MkdirAll(filepath.Dir(path), 0700) - _ = os.WriteFile(path, []byte(id+"\n"), 0600) - return id + buf[6] = (buf[6] & 0x0f) | 0x40 + buf[8] = (buf[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]), func() {} } // maxConsecutiveErrors is the number of consecutive connection errors before @@ -79,6 +122,9 @@ func loadOrGenerateUUID() string { const maxConsecutiveErrors = 10 func (w *Worker) Run(ctx context.Context) error { + if w.slotCleanup != nil { + defer w.slotCleanup() + } w.log.Info("starting") // Take initial snapshot for delta computation @@ -358,12 +404,16 @@ func (w *Worker) execute(ctx context.Context, job *ClaimResponse) { env = append(env, "BUILD_TMPDIR="+tmpDir) env = append(env, "BUILD_VCS_TYPE="+job.VCSType) env = append(env, "BUILD_VCS_URL="+job.VCSURL) + env = append(env, "BUILD_REF="+ref) if job.RepoID != "" { env = append(env, "BUILD_REPO_ID="+job.RepoID) } if checkout.SHA != "" { env = append(env, "BUILD_COMMIT_SHA="+checkout.SHA) } + if checkout.P4Client != "" { + env = append(env, "P4CLIENT="+checkout.P4Client) + } // Resolve env mappings from trigger config (if present) if len(job.EnvMappings) > 0 && job.MatrixValues != nil { diff --git a/core/base.go b/core/base.go index e7ba1bc..ad802b1 100644 --- a/core/base.go +++ b/core/base.go @@ -49,6 +49,7 @@ const ( CredentialTypeSSH CredentialType = iota CredentialTypeUsernamePassword CredentialTypeAccessKey + CredentialTypeP4 ) type Credentials interface { diff --git a/node_interfaces/interface_core_p4-credentials_v1.go b/node_interfaces/interface_core_p4-credentials_v1.go new file mode 100644 index 0000000..0f700b0 --- /dev/null +++ b/node_interfaces/interface_core_p4-credentials_v1.go @@ -0,0 +1,23 @@ +// Code generated by actrun. DO NOT EDIT. + +package node_interfaces + +import "github.com/actionforge/actrun-cli/core" // Bundles Perforce P4 connection details into a reusable credential object. + +// ==> (o) Inputs + +// The Perforce workspace/client name. Falls back to P4CLIENT env var if empty. +const Core_p4_credentials_v1_Input_client core.InputId = "client" +// The Perforce password or ticket. Falls back to P4PASSWD env var if empty. +const Core_p4_credentials_v1_Input_password core.InputId = "password" +// The Perforce server address. Falls back to P4PORT env var if empty. +const Core_p4_credentials_v1_Input_port core.InputId = "port" +// Path to the P4 trust file. Falls back to P4TRUST env var if empty. +const Core_p4_credentials_v1_Input_trust core.InputId = "trust" +// The Perforce username. Falls back to P4USER env var if empty. +const Core_p4_credentials_v1_Input_user core.InputId = "user" + +// Outputs (o) ==> + +// A credential object containing the Perforce P4 connection details. +const Core_p4_credentials_v1_Output_credential core.OutputId = "credential" diff --git a/node_interfaces/interface_core_p4-print_v1.go b/node_interfaces/interface_core_p4-print_v1.go new file mode 100644 index 0000000..3031a85 --- /dev/null +++ b/node_interfaces/interface_core_p4-print_v1.go @@ -0,0 +1,22 @@ +// Code generated by actrun. DO NOT EDIT. + +package node_interfaces + +import "github.com/actionforge/actrun-cli/core" // Downloads a file from the Perforce P4 depot without requiring a workspace. + +// ==> (o) Inputs + +// Optional P4 credentials. If not provided, falls back to P4 environment variables. +const Core_p4_print_v1_Input_credentials core.InputId = "credentials" +// The depot path of the file to download. +const Core_p4_print_v1_Input_depot_path core.InputId = "depot_path" +const Core_p4_print_v1_Input_exec core.InputId = "exec" +// The local file path where the downloaded file will be saved. +const Core_p4_print_v1_Input_output_path core.InputId = "output_path" + +// Outputs (o) ==> + +// Executes if there is an error downloading the file. +const Core_p4_print_v1_Output_exec_err core.OutputId = "exec-err" +// Executes if the file is downloaded successfully. +const Core_p4_print_v1_Output_exec_success core.OutputId = "exec-success" diff --git a/node_interfaces/interface_core_p4-run_v1.go b/node_interfaces/interface_core_p4-run_v1.go new file mode 100644 index 0000000..f7e7d9b --- /dev/null +++ b/node_interfaces/interface_core_p4-run_v1.go @@ -0,0 +1,26 @@ +// Code generated by actrun. DO NOT EDIT. + +package node_interfaces + +import "github.com/actionforge/actrun-cli/core" // Runs an arbitrary Perforce P4 command. + +// ==> (o) Inputs + +// Additional arguments for the p4 command. +const Core_p4_run_v1_Input_args core.InputId = "args" +// The p4 subcommand to run (e.g. files, changes, describe, client). +const Core_p4_run_v1_Input_command core.InputId = "command" +// Optional P4 credentials. If not provided, falls back to P4 environment variables. +const Core_p4_run_v1_Input_credentials core.InputId = "credentials" +const Core_p4_run_v1_Input_exec core.InputId = "exec" + +// Outputs (o) ==> + +// Executes if the command fails (non-zero exit code). +const Core_p4_run_v1_Output_exec_err core.OutputId = "exec-err" +// Executes if the command succeeds (exit code 0). +const Core_p4_run_v1_Output_exec_success core.OutputId = "exec-success" +// The exit code of the p4 command. +const Core_p4_run_v1_Output_exit_code core.OutputId = "exit_code" +// The combined stdout/stderr output from the p4 command. +const Core_p4_run_v1_Output_output core.OutputId = "output" diff --git a/node_interfaces/interface_core_p4-sync_v1.go b/node_interfaces/interface_core_p4-sync_v1.go new file mode 100644 index 0000000..aa8cad2 --- /dev/null +++ b/node_interfaces/interface_core_p4-sync_v1.go @@ -0,0 +1,26 @@ +// Code generated by actrun. DO NOT EDIT. + +package node_interfaces + +import "github.com/actionforge/actrun-cli/core" // Syncs files from a Perforce P4 depot path. + +// ==> (o) Inputs + +// The Perforce workspace name. Overrides the value from credentials or P4CLIENT env var. +const Core_p4_sync_v1_Input_client core.InputId = "client" +// Optional P4 credentials. If not provided, falls back to P4PORT/P4USER/P4PASSWD environment variables. +const Core_p4_sync_v1_Input_credentials core.InputId = "credentials" +// The Perforce depot path to sync (e.g. //depot/project/...). +const Core_p4_sync_v1_Input_depot_path core.InputId = "depot_path" +const Core_p4_sync_v1_Input_exec core.InputId = "exec" +// If true, forces the sync (-f flag), re-downloading all files regardless of have list. +const Core_p4_sync_v1_Input_force core.InputId = "force" + +// Outputs (o) ==> + +// Executes if there is an error during sync. +const Core_p4_sync_v1_Output_exec_err core.OutputId = "exec-err" +// Executes if the sync operation is successful. +const Core_p4_sync_v1_Output_exec_success core.OutputId = "exec-success" +// The stdout/stderr output from the p4 sync command. +const Core_p4_sync_v1_Output_output core.OutputId = "output" diff --git a/nodes/gh-action@v1.go b/nodes/gh-action@v1.go index d8e2ca1..0b89567 100644 --- a/nodes/gh-action@v1.go +++ b/nodes/gh-action@v1.go @@ -41,7 +41,7 @@ var ( // 3. ([-\w\.]+) -> Repo Name (Required) // 4. (/[^@]+)? -> Path (Optional). Matches slash followed by anything NOT an @ // 5. (@[-\w\.]+)? -> Ref/Version (Optional). Matches @ followed by chars -var nodeTypeIdRegex = regexp.MustCompile(`^(github.com/)?([-\w\.]+)/([-\w\.]+)(/[^@]+)?(@[-\w\.]+)?$`) +var nodeTypeIdRegex = regexp.MustCompile(`^(github\.com/)?([-\w\.]+)/([-\w\.]+)(/[^@]+)?(@[-\w\.]+)?$`) type ActionType int diff --git a/nodes/p4-credentials@v1.go b/nodes/p4-credentials@v1.go new file mode 100644 index 0000000..818e5b3 --- /dev/null +++ b/nodes/p4-credentials@v1.go @@ -0,0 +1,48 @@ +//go:build p4 + +package nodes + +import ( + _ "embed" + + "github.com/actionforge/actrun-cli/core" + ni "github.com/actionforge/actrun-cli/node_interfaces" +) + +//go:embed p4-credentials@v1.yml +var p4CredentialsDefinition string + +type P4CredentialsNode struct { + core.NodeBaseComponent + core.Inputs + core.Outputs +} + +func (n *P4CredentialsNode) OutputValueById(c *core.ExecutionState, outputId core.OutputId) (any, error) { + if outputId != ni.Core_p4_credentials_v1_Output_credential { + return nil, core.CreateErr(c, nil, "unknown output id '%s'", outputId) + } + + port, _ := core.InputValueById[core.SecretValue](c, n, ni.Core_p4_credentials_v1_Input_port) + user, _ := core.InputValueById[core.SecretValue](c, n, ni.Core_p4_credentials_v1_Input_user) + password, _ := core.InputValueById[core.SecretValue](c, n, ni.Core_p4_credentials_v1_Input_password) + trust, _ := core.InputValueById[string](c, n, ni.Core_p4_credentials_v1_Input_trust) + client, _ := core.InputValueById[string](c, n, ni.Core_p4_credentials_v1_Input_client) + + return P4Credentials{ + Port: port.Secret, + User: user.Secret, + Password: password.Secret, + Trust: trust, + Client: client, + }, nil +} + +func init() { + err := core.RegisterNodeFactory(p4CredentialsDefinition, func(ctx any, parent core.NodeBaseInterface, parentId string, nodeDef map[string]any, validate bool, opts core.RunOpts) (core.NodeBaseInterface, []error) { + return &P4CredentialsNode{}, nil + }) + if err != nil { + panic(err) + } +} diff --git a/nodes/p4-credentials@v1.yml b/nodes/p4-credentials@v1.yml new file mode 100644 index 0000000..e012cb0 --- /dev/null +++ b/nodes/p4-credentials@v1.yml @@ -0,0 +1,59 @@ +yaml-version: 3.0 +id: core/p4-credentials +name: P4 Credentials +version: 1 +category: perforce +icon: tablerServer +style: + header: + background: "#07a3ee" + color: "#ffffff" + body: + background: "#055f8a" +short_desc: Bundles Perforce P4 connection details into a reusable credential object. +addendum: | + This data node holds Perforce P4 server connection details and outputs them as a credential object. + + Connect the `credential` output to P4 nodes that accept a `credentials` input. + + When fields are left empty, the corresponding P4 environment variables are used as fallback at execution time. +compact: true +outputs: + credential: + type: credentials + desc: A credential object containing the Perforce P4 connection details. + index: 0 +inputs: + port: + type: secret + name: P4PORT + hint: "ssl:perforce.example.com:1666" + desc: The Perforce server address. Falls back to P4PORT env var if empty. + hide_socket: true + index: 0 + user: + type: secret + name: P4USER + hint: "joe" + desc: The Perforce username. Falls back to P4USER env var if empty. + hide_socket: true + index: 1 + password: + type: secret + name: P4PASSWD + desc: The Perforce password or ticket. Falls back to P4PASSWD env var if empty. + hide_socket: true + index: 2 + trust: + type: string + name: P4TRUST + desc: Path to the P4 trust file. Falls back to P4TRUST env var if empty. + optional: true + index: 3 + client: + type: string + name: P4CLIENT + hint: "my-workspace" + desc: The Perforce workspace/client name. Falls back to P4CLIENT env var if empty. + optional: true + index: 4 diff --git a/nodes/p4-print@v1.go b/nodes/p4-print@v1.go new file mode 100644 index 0000000..fbdb4cc --- /dev/null +++ b/nodes/p4-print@v1.go @@ -0,0 +1,73 @@ +//go:build p4 + +package nodes + +import ( + _ "embed" + "os" + "path/filepath" + + "github.com/actionforge/actrun-cli/core" + ni "github.com/actionforge/actrun-cli/node_interfaces" +) + +//go:embed p4-print@v1.yml +var p4PrintDefinition string + +type P4PrintNode struct { + core.NodeBaseComponent + core.Executions + core.Inputs + core.Outputs +} + +func (n *P4PrintNode) ExecuteImpl(c *core.ExecutionState, inputId core.InputId, prevError error) error { + depotPath, err := core.InputValueById[string](c, n, ni.Core_p4_print_v1_Input_depot_path) + if err != nil { + return err + } + + outputPath, err := core.InputValueById[string](c, n, ni.Core_p4_print_v1_Input_output_path) + if err != nil { + return err + } + + creds, _ := core.InputValueById[core.Credentials](c, n, ni.Core_p4_print_v1_Input_credentials) + + fields := buildP4Fields(c, creds) + + p4, err := connectP4(c, fields) + if err != nil { + return n.Execute(ni.Core_p4_print_v1_Output_exec_err, c, err) + } + defer func() { + p4.Disconnect() + p4.Close() + }() + + // Ensure parent directory exists + if dir := filepath.Dir(outputPath); dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + mkdirErr := core.CreateErr(c, err, "failed to create output directory '%s'", dir) + return n.Execute(ni.Core_p4_print_v1_Output_exec_err, c, mkdirErr) + } + } + + // p4 print -q -o + _, runErr := p4.Run("print", "-q", "-o", outputPath, depotPath) + if runErr != nil { + printErr := core.CreateErr(c, runErr, "p4 print failed for %s", depotPath) + return n.Execute(ni.Core_p4_print_v1_Output_exec_err, c, printErr) + } + + return n.Execute(ni.Core_p4_print_v1_Output_exec_success, c, nil) +} + +func init() { + err := core.RegisterNodeFactory(p4PrintDefinition, func(ctx any, parent core.NodeBaseInterface, parentId string, nodeDef map[string]any, validate bool, opts core.RunOpts) (core.NodeBaseInterface, []error) { + return &P4PrintNode{}, nil + }) + if err != nil { + panic(err) + } +} diff --git a/nodes/p4-print@v1.yml b/nodes/p4-print@v1.yml new file mode 100644 index 0000000..0dcb172 --- /dev/null +++ b/nodes/p4-print@v1.yml @@ -0,0 +1,53 @@ +yaml-version: 3.0 +id: core/p4-print +name: P4 Print +version: 1 +category: perforce +icon: tablerFileDownload +style: + header: + background: "#07a3ee" + color: "#ffffff" + body: + background: "#055f8a" +short_desc: Downloads a file from the Perforce P4 depot without requiring a workspace. +addendum: | + Runs `p4 print -q -o ` to download a single file from the depot directly to disk. + + This does not require a workspace (P4CLIENT) to be set, making it useful for fetching individual files. + + For SSL servers, `p4 trust -y` is automatically executed before the command. +outputs: + exec-success: + name: Success + exec: true + desc: Executes if the file is downloaded successfully. + index: 0 + exec-err: + name: Error + exec: true + desc: Executes if there is an error downloading the file. + index: 1 +inputs: + exec: + exec: true + index: 0 + depot_path: + type: string + name: Depot Path + hint: "//depot/project/README.md" + desc: The depot path of the file to download. + index: 1 + required: true + output_path: + type: string + name: Output Path + desc: The local file path where the downloaded file will be saved. + index: 2 + required: true + credentials: + type: credentials + name: Credentials + desc: Optional P4 credentials. If not provided, falls back to P4 environment variables. + index: 3 + optional: true diff --git a/nodes/p4-run@v1.go b/nodes/p4-run@v1.go new file mode 100644 index 0000000..a52d0fd --- /dev/null +++ b/nodes/p4-run@v1.go @@ -0,0 +1,65 @@ +//go:build p4 + +package nodes + +import ( + _ "embed" + + "github.com/actionforge/actrun-cli/core" + ni "github.com/actionforge/actrun-cli/node_interfaces" +) + +//go:embed p4-run@v1.yml +var p4RunDefinition string + +type P4RunNode struct { + core.NodeBaseComponent + core.Executions + core.Inputs + core.Outputs +} + +func (n *P4RunNode) ExecuteImpl(c *core.ExecutionState, inputId core.InputId, prevError error) error { + command, err := core.InputValueById[string](c, n, ni.Core_p4_run_v1_Input_command) + if err != nil { + return err + } + + extraArgs, _ := core.InputValueById[[]string](c, n, ni.Core_p4_run_v1_Input_args) + creds, _ := core.InputValueById[core.Credentials](c, n, ni.Core_p4_run_v1_Input_credentials) + + fields := buildP4Fields(c, creds) + + p4, err := connectP4(c, fields) + if err != nil { + _ = n.SetOutputValue(c, ni.Core_p4_run_v1_Output_exit_code, 1, core.SetOutputValueOpts{}) + return n.Execute(ni.Core_p4_run_v1_Output_exec_err, c, err) + } + defer func() { + p4.Disconnect() + p4.Close() + }() + + results, runErr := p4.Run(command, extraArgs...) + output := formatP4Results(results) + + _ = n.SetOutputValue(c, ni.Core_p4_run_v1_Output_output, output, core.SetOutputValueOpts{}) + + if runErr != nil { + _ = n.SetOutputValue(c, ni.Core_p4_run_v1_Output_exit_code, 1, core.SetOutputValueOpts{}) + cmdErr := core.CreateErr(c, runErr, "p4 %s failed", command) + return n.Execute(ni.Core_p4_run_v1_Output_exec_err, c, cmdErr) + } + + _ = n.SetOutputValue(c, ni.Core_p4_run_v1_Output_exit_code, 0, core.SetOutputValueOpts{}) + return n.Execute(ni.Core_p4_run_v1_Output_exec_success, c, nil) +} + +func init() { + err := core.RegisterNodeFactory(p4RunDefinition, func(ctx any, parent core.NodeBaseInterface, parentId string, nodeDef map[string]any, validate bool, opts core.RunOpts) (core.NodeBaseInterface, []error) { + return &P4RunNode{}, nil + }) + if err != nil { + panic(err) + } +} diff --git a/nodes/p4-run@v1.yml b/nodes/p4-run@v1.yml new file mode 100644 index 0000000..cac6a14 --- /dev/null +++ b/nodes/p4-run@v1.yml @@ -0,0 +1,64 @@ +yaml-version: 3.0 +id: core/p4-run +name: P4 Run +version: 1 +category: perforce +icon: tablerTerminal +style: + header: + background: "#07a3ee" + color: "#ffffff" + body: + background: "#055f8a" +short_desc: Runs an arbitrary Perforce P4 command. +addendum: | + Executes any Perforce command via the `p4` CLI. Use this for commands not covered by dedicated P4 nodes (e.g. `p4 files`, `p4 changes`, `p4 client`). + + The `command` input is the p4 subcommand (e.g. "files", "changes", "describe"). + Additional arguments go into the `args` input. + + For SSL servers, `p4 trust -y` is automatically executed before the command. +outputs: + exec-success: + name: Success + exec: true + desc: Executes if the command succeeds (exit code 0). + index: 0 + exec-err: + name: Error + exec: true + desc: Executes if the command fails (non-zero exit code). + index: 1 + output: + type: string + name: Output + desc: The combined stdout/stderr output from the p4 command. + index: 2 + exit_code: + name: Exit Code + type: number + desc: The exit code of the p4 command. + index: 3 +inputs: + exec: + exec: true + index: 0 + command: + type: string + name: Command + hint: "files" + desc: The p4 subcommand to run (e.g. files, changes, describe, client). + index: 1 + required: true + args: + name: Args + type: "[]string" + desc: Additional arguments for the p4 command. + index: 2 + optional: true + credentials: + type: credentials + name: Credentials + desc: Optional P4 credentials. If not provided, falls back to P4 environment variables. + index: 3 + optional: true diff --git a/nodes/p4-sync@v1.go b/nodes/p4-sync@v1.go new file mode 100644 index 0000000..482d6a2 --- /dev/null +++ b/nodes/p4-sync@v1.go @@ -0,0 +1,71 @@ +//go:build p4 + +package nodes + +import ( + _ "embed" + + "github.com/actionforge/actrun-cli/core" + ni "github.com/actionforge/actrun-cli/node_interfaces" +) + +//go:embed p4-sync@v1.yml +var p4SyncDefinition string + +type P4SyncNode struct { + core.NodeBaseComponent + core.Executions + core.Inputs + core.Outputs +} + +func (n *P4SyncNode) ExecuteImpl(c *core.ExecutionState, inputId core.InputId, prevError error) error { + depotPath, err := core.InputValueById[string](c, n, ni.Core_p4_sync_v1_Input_depot_path) + if err != nil { + return err + } + + creds, _ := core.InputValueById[core.Credentials](c, n, ni.Core_p4_sync_v1_Input_credentials) + client, _ := core.InputValueById[string](c, n, ni.Core_p4_sync_v1_Input_client) + force, _ := core.InputValueById[bool](c, n, ni.Core_p4_sync_v1_Input_force) + + fields := buildP4Fields(c, creds) + if client != "" { + fields.Client = client + } + + p4, err := connectP4(c, fields) + if err != nil { + return n.Execute(ni.Core_p4_sync_v1_Output_exec_err, c, err) + } + defer func() { + p4.Disconnect() + p4.Close() + }() + + var syncArgs []string + if force { + syncArgs = append(syncArgs, "-f") + } + syncArgs = append(syncArgs, depotPath) + + results, runErr := p4.Run("sync", syncArgs...) + output := formatP4Results(results) + + _ = n.SetOutputValue(c, ni.Core_p4_sync_v1_Output_output, output, core.SetOutputValueOpts{}) + + if runErr != nil { + syncErr := core.CreateErr(c, runErr, "p4 sync failed") + return n.Execute(ni.Core_p4_sync_v1_Output_exec_err, c, syncErr) + } + return n.Execute(ni.Core_p4_sync_v1_Output_exec_success, c, nil) +} + +func init() { + err := core.RegisterNodeFactory(p4SyncDefinition, func(ctx any, parent core.NodeBaseInterface, parentId string, nodeDef map[string]any, validate bool, opts core.RunOpts) (core.NodeBaseInterface, []error) { + return &P4SyncNode{}, nil + }) + if err != nil { + panic(err) + } +} diff --git a/nodes/p4-sync@v1.yml b/nodes/p4-sync@v1.yml new file mode 100644 index 0000000..ebbfbb2 --- /dev/null +++ b/nodes/p4-sync@v1.yml @@ -0,0 +1,65 @@ +yaml-version: 3.0 +id: core/p4-sync +name: P4 Sync +version: 1 +category: perforce +icon: tablerRefresh +style: + header: + background: "#07a3ee" + color: "#ffffff" + body: + background: "#055f8a" +short_desc: Syncs files from a Perforce P4 depot path. +addendum: | + Runs `p4 sync` to download files from a depot path to the local workspace. + + Requires a Perforce workspace (P4CLIENT) to be set, either via the credentials input, environment variable, or the `client` input. + + For SSL servers, `p4 trust -y` is automatically executed before syncing. +outputs: + exec-success: + name: Success + exec: true + desc: Executes if the sync operation is successful. + index: 0 + exec-err: + name: Error + exec: true + desc: Executes if there is an error during sync. + index: 1 + output: + type: string + name: Output + desc: The stdout/stderr output from the p4 sync command. + index: 2 +inputs: + exec: + exec: true + index: 0 + depot_path: + type: string + name: Depot Path + hint: "//depot/..." + desc: The Perforce depot path to sync (e.g. //depot/project/...). + index: 1 + required: true + credentials: + type: credentials + name: Credentials + desc: Optional P4 credentials. If not provided, falls back to P4PORT/P4USER/P4PASSWD environment variables. + index: 2 + optional: true + client: + type: string + name: P4CLIENT + desc: The Perforce workspace name. Overrides the value from credentials or P4CLIENT env var. + index: 3 + optional: true + force: + type: bool + name: Force + desc: If true, forces the sync (-f flag), re-downloading all files regardless of have list. + index: 4 + optional: true + default: false diff --git a/nodes/p4-utils.go b/nodes/p4-utils.go new file mode 100644 index 0000000..a4972e8 --- /dev/null +++ b/nodes/p4-utils.go @@ -0,0 +1,174 @@ +//go:build p4 + +package nodes + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/actionforge/actrun-cli/core" + p4go "github.com/perforce/p4go" +) + +// P4Credentials bundles Perforce connection details into a credential object. +type P4Credentials struct { + Port string + User string + Password string + Trust string + Client string +} + +func (p P4Credentials) Type() core.CredentialType { + return core.CredentialTypeP4 +} + +// p4EnvFields holds resolved P4 connection details from credentials and/or environment. +type p4EnvFields struct { + Port string + User string + Password string + Trust string + Client string +} + +// buildP4Fields resolves P4 connection details from credentials (if provided) +// with fallback to the graph/OS environment for any empty field. +func buildP4Fields(c *core.ExecutionState, creds core.Credentials) p4EnvFields { + var p4 P4Credentials + if creds != nil { + if pc, ok := creds.(P4Credentials); ok { + p4 = pc + } + } + + resolve := func(credVal, envKey string) string { + if credVal != "" { + return credVal + } + return envOrOs(c, envKey) + } + + return p4EnvFields{ + Port: resolve(p4.Port, "P4PORT"), + User: resolve(p4.User, "P4USER"), + Password: resolve(p4.Password, "P4PASSWD"), + Trust: resolve(p4.Trust, "P4TRUST"), + Client: resolve(p4.Client, "P4CLIENT"), + } +} + +// connectP4 creates a p4go client, establishes trust for SSL, authenticates, +// and returns the connected client. Caller must call p4.Disconnect() and p4.Close(). +func connectP4(c *core.ExecutionState, fields p4EnvFields) (*p4go.P4, error) { + p4 := p4go.New() + + if fields.Port == "" { + return nil, core.CreateErr(c, nil, "P4PORT is not set. Provide it via credentials or environment variable.") + } + p4.SetPort(fields.Port) + + if fields.User != "" { + p4.SetUser(fields.User) + } + if fields.Password != "" { + p4.SetPassword(fields.Password) + } + if fields.Client != "" { + p4.SetClient(fields.Client) + } + + // SSL trust handling + if strings.HasPrefix(fields.Port, "ssl:") { + trustFile := fields.Trust + if trustFile == "" { + trustFile = filepath.Join(os.TempDir(), ".p4trust") + } + p4.SetTrustFile(trustFile) + } + + connected, err := p4.Connect() + if !connected { + p4.Close() + return nil, core.CreateErr(c, err, "failed to connect to Perforce server at %s", fields.Port) + } + + // Accept SSL fingerprint + if strings.HasPrefix(fields.Port, "ssl:") { + p4.Run("trust", "-y") + } + + // Authenticate if password is set + if fields.Password != "" { + if _, err := p4.RunLogin(); err != nil { + p4.Disconnect() + p4.Close() + return nil, core.CreateErr(c, err, "P4 login failed for user %s", fields.User) + } + } + + return p4, nil +} + +// runP4Cmd connects, runs a p4 command, and returns the output. +func runP4Cmd(c *core.ExecutionState, fields p4EnvFields, cmd string, args ...string) (string, error) { + p4, err := connectP4(c, fields) + if err != nil { + return "", err + } + defer func() { + p4.Disconnect() + p4.Close() + }() + + results, err := p4.Run(cmd, args...) + if err != nil { + return "", core.CreateErr(c, err, "p4 %s failed", cmd) + } + + return formatP4Results(results), nil +} + +// formatP4Results converts p4go results to a human-readable string. +func formatP4Results(results []p4go.P4Result) string { + var lines []string + for _, r := range results { + switch v := r.(type) { + case p4go.P4Data: + if s := strings.TrimSpace(string(v)); s != "" { + lines = append(lines, s) + } + case p4go.Dictionary: + line := formatP4Dict(v) + if line != "" { + lines = append(lines, line) + } + case p4go.P4Message: + if s := v.String(); s != "" { + lines = append(lines, strings.TrimSpace(s)) + } + default: + lines = append(lines, fmt.Sprintf("%v", r)) + } + } + return strings.Join(lines, "\n") +} + +// formatP4Dict formats a p4go Dictionary into a human-readable line. +func formatP4Dict(d p4go.Dictionary) string { + if depotFile, ok := d["depotFile"]; ok { + rev, _ := d["rev"] + action, _ := d["action"] + change, _ := d["change"] + fileType, _ := d["type"] + return fmt.Sprintf("%v#%v - %v change %v (%v)", depotFile, rev, action, change, fileType) + } + // Generic fallback + var parts []string + for k, v := range d { + parts = append(parts, fmt.Sprintf("%s=%v", k, v)) + } + return strings.Join(parts, " ") +} diff --git a/tests_e2e/references/reference_p4_connect.sh_l5 b/tests_e2e/references/reference_p4_connect.sh_l5 new file mode 100644 index 0000000..f5dd617 --- /dev/null +++ b/tests_e2e/references/reference_p4_connect.sh_l5 @@ -0,0 +1,3 @@ +//depot/pong/background.jpg#1 - add change 3 (binary+F) +//depot/pong/build-pong-game.act#1 - add change 3 (text) +//depot/pong/tutorial-readme.md#1 - add change 3 (text) diff --git a/tests_e2e/scripts/p4_connect.act b/tests_e2e/scripts/p4_connect.act new file mode 100644 index 0000000..7a71c1e --- /dev/null +++ b/tests_e2e/scripts/p4_connect.act @@ -0,0 +1,47 @@ +editor: + version: + created: v1.0.0 +entry: start +type: generic +nodes: + - id: start + type: core/start@v1 + position: + x: 0 + y: 0 + - id: list-files + type: core/p4-run@v1 + position: + x: 200 + y: 0 + inputs: + command: files + args: + - "//depot/pong/..." + - id: print-result + type: core/print@v1 + position: + x: 300 + y: 0 + inputs: + values[0]: null +connections: + - src: + node: list-files + port: output + dst: + node: print-result + port: "values[0]" +executions: + - src: + node: start + port: exec + dst: + node: list-files + port: exec + - src: + node: list-files + port: exec-success + dst: + node: print-result + port: exec diff --git a/tests_e2e/scripts/p4_connect.sh b/tests_e2e/scripts/p4_connect.sh new file mode 100644 index 0000000..903f3f1 --- /dev/null +++ b/tests_e2e/scripts/p4_connect.sh @@ -0,0 +1,5 @@ +export P4PORT=ssl:p4demo.actionforge.dev:1666 +export P4USER=guest +export P4PASSWD=readonly1 + +#! test actrun ${ACT_GRAPH_FILES_DIR}${PATH_SEPARATOR}p4_connect.act