Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ Kubernetes 1.29 or newer is required.
Open Actions supports event, manual, and scheduled triggers; independent jobs
selected by runner labels; Bash steps; step and declared job outputs; Node 20,
Node 24, and composite actions; expressions and concurrency; optional
job-scoped Docker; GitHub Check Runs; and live logs. See the
job-scoped Docker; approval-gated environments and environment secrets; GitHub
Check Runs; and live logs. See the
[Workflow API](docs/reference.md#workflow-api) for the exact supported syntax
and execution constraints. Unsupported workflows fail explicitly rather than
run with different semantics.
Expand Down
1 change: 1 addition & 0 deletions api/v1alpha1/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ const (
AnnotationRunnerName = "actions.kelos.dev/runner-name"
AnnotationRunnerResultVersion = "actions.kelos.dev/runner-result-version"
AnnotationProjectName = "actions.kelos.dev/project-name"
AnnotationEnvironmentApproved = "actions.kelos.dev/environment-approved"
)
48 changes: 48 additions & 0 deletions api/v1alpha1/project_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const SourceTypeGitHub SourceType = "GitHub"
type SourceType string

// ProjectSpec describes the workflow source for an Open Actions Project.
// +kubebuilder:validation:XValidation:rule="!has(self.environments) || self.environments.all(e, self.environments.exists_one(other, e.name.lowerAscii() == other.name.lowerAscii()))",message="environment names must be unique ignoring ASCII case"
type ProjectSpec struct {
// Source selects and configures the external workflow source.
// +required
Expand All @@ -27,6 +28,53 @@ type ProjectSpec struct {
// +kubebuilder:validation:XValidation:rule="self != '..' && !self.startsWith('../') && !self.contains('/../') && !self.endsWith('/..')",message="must not contain '..' path segments"
// +optional
WorkflowDirectory string `json:"workflowDirectory,omitempty"`

// Environments defines the environment names workflows may select. Each
// environment may expose one Secret and require approval before its jobs can
// be assigned to a Runner.
// +listType=map
// +listMapKey=name
// +kubebuilder:validation:MaxItems=100
// +optional
Environments []ProjectEnvironment `json:"environments,omitempty"`
}

// ProjectEnvironment configures one workflow environment. Environment names
// are matched without regard to ASCII case.
type ProjectEnvironment struct {
// Name is the GitHub-compatible environment name selected by a workflow job.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=255
// +kubebuilder:validation:Pattern=`^[^\x00-\x1f\x7f]+$`
// +required
Name string `json:"name"`

// SecretRef identifies a Secret in the Project namespace whose data keys
// populate the secrets expression context for jobs in this environment.
// +optional
SecretRef *EnvironmentSecretReference `json:"secretRef,omitempty"`

// Protection configures the gate enforced before a job can be assigned.
// +optional
Protection *EnvironmentProtection `json:"protection,omitempty"`
}

// EnvironmentSecretReference identifies a Secret in the same namespace.
type EnvironmentSecretReference struct {
// Name is the Secret resource name.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?([.][a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?)*$`
// +required
Name string `json:"name"`
}

// EnvironmentProtection configures the Open Actions environment gate.
type EnvironmentProtection struct {
// RequiredApproval requires an authorized user to approve each WorkflowJob
// before a Runner can claim it.
// +optional
RequiredApproval bool `json:"requiredApproval,omitempty"`
}

// ProjectSource is a discriminated union of supported workflow sources.
Expand Down
45 changes: 40 additions & 5 deletions api/v1alpha1/workflowjob_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import (
)

const (
WorkflowJobConditionScheduled = "Scheduled"
WorkflowJobConditionSucceeded = "Succeeded"
WorkflowJobConditionEnvironmentApproved = "EnvironmentApproved"
WorkflowJobConditionScheduled = "Scheduled"
WorkflowJobConditionSucceeded = "Succeeded"
)

// WorkflowJobSpec describes one immutable job expanded from a WorkflowRun.
Expand Down Expand Up @@ -52,6 +53,37 @@ type WorkflowJobSpec struct {
// Matrix describes the matrix combination represented by this job.
// +optional
Matrix *WorkflowJobMatrix `json:"matrix,omitempty"`

// Environment contains the selected workflow environment and the Project
// policy resolved for this job.
// +optional
Environment *WorkflowJobEnvironment `json:"environment,omitempty"`
}

// WorkflowJobEnvironment contains the environment configuration frozen when a
// WorkflowJob is planned.
type WorkflowJobEnvironment struct {
// Name is the configured Project environment name.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=255
// +kubebuilder:validation:Pattern=`^[^\x00-\x1f\x7f]+$`
// +required
Name string `json:"name"`

// URL is the optional deployment target URL resolved from the workflow.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=2048
// +optional
URL string `json:"url,omitempty"`

// SecretRef identifies the Project Secret whose data keys are available to
// this job through the secrets expression context.
// +optional
SecretRef *EnvironmentSecretReference `json:"secretRef,omitempty"`

// Protection is the Project environment gate resolved for this job.
// +optional
Protection *EnvironmentProtection `json:"protection,omitempty"`
}

// WorkflowJobMatrix identifies one expanded combination of a logical workflow
Expand Down Expand Up @@ -110,9 +142,10 @@ type WorkflowJobStatus struct {
// +optional
Outputs map[string]string `json:"outputs,omitempty"`

// Conditions describe Runner assignment and the terminal result. Scheduled
// is true after the scheduler assigns status.runnerRef. Known condition types
// are Scheduled and Succeeded.
// Conditions describe environment approval, Runner assignment, and the
// terminal result. EnvironmentApproved is present for jobs that select an
// environment. Scheduled is true after the scheduler assigns status.runnerRef.
// Known condition types are EnvironmentApproved, Scheduled, and Succeeded.
// +listType=map
// +listMapKey=type
// +kubebuilder:validation:MaxItems=16
Expand All @@ -126,6 +159,8 @@ type WorkflowJobStatus struct {
// +kubebuilder:printcolumn:name="Job",type=string,JSONPath=`.spec.displayName`
// +kubebuilder:printcolumn:name="WorkflowRun",type=string,JSONPath=`.spec.workflowRunRef.name`
// +kubebuilder:printcolumn:name="Runner",type=string,JSONPath=`.status.runnerRef.name`
// +kubebuilder:printcolumn:name="Environment",type=string,JSONPath=`.spec.environment.name`
// +kubebuilder:printcolumn:name="Approved",type=string,JSONPath=`.status.conditions[?(@.type=="EnvironmentApproved")].status`
// +kubebuilder:printcolumn:name="Scheduled",type=string,JSONPath=`.status.conditions[?(@.type=="Scheduled")].status`
// +kubebuilder:printcolumn:name="Succeeded",type=string,JSONPath=`.status.conditions[?(@.type=="Succeeded")].status`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
Expand Down
7 changes: 7 additions & 0 deletions api/v1alpha1/workflowrun_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,13 @@ type WorkflowRunJobStatus struct {
// +optional
Total int32 `json:"total,omitempty"`

// WaitingForApproval is the number of jobs blocked by an environment
// approval gate and therefore not yet queued for a Runner.
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=100000
// +optional
WaitingForApproval int32 `json:"waitingForApproval,omitempty"`

// Queued is the number of jobs waiting for a matching Runner.
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=100000
Expand Down
92 changes: 92 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions cmd/open-actions-runner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func main() {
func run(ctx context.Context, arguments []string) error {
flags := flag.NewFlagSet("open-actions-runner", flag.ContinueOnError)
jobFile := flags.String("job-file", "/var/run/open-actions/job.json", "Path to the workflow job plan")
secretsFile := flags.String("secrets-file", "/var/run/open-actions-credentials/secrets.json", "Path to the environment-scoped workflow job secrets")
resultFile := flags.String("result-file", "/dev/termination-log", "Path used to report the workflow job result")
workspace := flags.String("workspace", "/workspace", "Path to the job workspace")
if err := flags.Parse(arguments); err != nil {
Expand All @@ -35,10 +36,19 @@ func run(ctx context.Context, arguments []string) error {
if err != nil {
return err
}
secrets, err := runner.LoadSecrets(*secretsFile)
if err != nil {
if plan.Version < runner.PlanVersion && errors.Is(err, os.ErrNotExist) {
secrets = map[string]string{}
} else {
return err
}
}
githubToken := os.Getenv("OPEN_ACTIONS_GITHUB_TOKEN")
executor, err := runner.NewExecutor(runner.ExecutorConfig{
Logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)),
GitHubToken: githubToken,
Secrets: secrets,
Environment: withoutEnvironmentVariable(os.Environ(), "OPEN_ACTIONS_GITHUB_TOKEN"),
Stdout: os.Stdout,
Stderr: os.Stderr,
Expand Down
36 changes: 35 additions & 1 deletion cmd/open-actions-runner/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ func TestRunWritesWorkflowJobResult(t *testing.T) {
t.Fatal(err)
}
resultPath := filepath.Join(directory, "result.json")
secretsPath := filepath.Join(directory, "secrets.json")
if err := os.WriteFile(secretsPath, []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("OPEN_ACTIONS_GITHUB_TOKEN", "installation-token")
if err := run(context.Background(), []string{"--job-file=" + planPath, "--result-file=" + resultPath, "--workspace=" + filepath.Join(directory, "workspace")}); err != nil {
if err := run(context.Background(), []string{"--job-file=" + planPath, "--secrets-file=" + secretsPath, "--result-file=" + resultPath, "--workspace=" + filepath.Join(directory, "workspace")}); err != nil {
t.Fatal(err)
}
resultData, err := os.ReadFile(resultPath)
Expand Down Expand Up @@ -67,3 +71,33 @@ func TestWithoutEnvironmentVariable(t *testing.T) {
t.Fatalf("filtered environment = %#v", environment)
}
}

func TestRunCompatiblePlanWithoutSecretsFile(t *testing.T) {
directory := t.TempDir()
plan := runner.Plan{
Version: runner.PlanVersion - 1,
Repository: runner.Repository{
ID: 1, Owner: "acme", Name: "example", ServerURL: "https://github.com", APIURL: "https://api.github.com", ActionCloneBaseURL: "https://github.com",
},
Event: runner.Event{Name: "push", DeliveryID: "delivery"}, Revision: runner.Revision{SHA: strings.Repeat("a", 40), Ref: "refs/heads/main", RefName: "main"},
WorkflowName: "CI", JobID: "build", Steps: []runner.Step{{Run: "true"}},
}
planData, err := json.Marshal(plan)
if err != nil {
t.Fatal(err)
}
planPath := filepath.Join(directory, "plan.json")
if err := os.WriteFile(planPath, planData, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("OPEN_ACTIONS_GITHUB_TOKEN", "installation-token")
err = run(context.Background(), []string{
"--job-file=" + planPath,
"--secrets-file=" + filepath.Join(directory, "missing.json"),
"--result-file=" + filepath.Join(directory, "result.json"),
"--workspace=" + filepath.Join(directory, "workspace"),
})
if err != nil {
t.Fatal(err)
}
}
9 changes: 7 additions & 2 deletions cmd/open-actions/run_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,21 +376,26 @@ func writeWorkflowRun(writer io.Writer, run *actionsv1alpha1.WorkflowRun, jobs [
fmt.Fprintf(table, "Started:\t%s\n", optionalTime(run.Status.StartTime))
fmt.Fprintf(table, "Completed:\t%s\n", optionalTime(run.Status.CompletionTime))
fmt.Fprintln(table)
fmt.Fprintln(table, "JOB\tRESOURCE\tDISPLAY NAME\tSTATUS\tRUNNER\tSTARTED\tCOMPLETED")
fmt.Fprintln(table, "JOB\tRESOURCE\tDISPLAY NAME\tENVIRONMENT\tSTATUS\tRUNNER\tSTARTED\tCOMPLETED")
for index := range jobs {
job := &jobs[index]
displayName := job.Spec.DisplayName
if displayName == "" || displayName == job.Spec.JobID {
displayName = "-"
}
runnerName := "-"
environmentName := "-"
if job.Spec.Environment != nil {
environmentName = job.Spec.Environment.Name
}
if job.Status.RunnerRef != nil {
runnerName = job.Status.RunnerRef.Name
}
fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
job.Spec.JobID,
job.Name,
tableCell(displayName),
tableCell(environmentName),
workflowstatus.Job(job),
runnerName,
optionalTime(job.Status.StartTime),
Expand Down
Loading
Loading