diff --git a/README.md b/README.md index 2c74d48..526a3f6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/api/v1alpha1/labels.go b/api/v1alpha1/labels.go index 33aa48f..023bb79 100644 --- a/api/v1alpha1/labels.go +++ b/api/v1alpha1/labels.go @@ -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" ) diff --git a/api/v1alpha1/project_types.go b/api/v1alpha1/project_types.go index 46e84c2..040a437 100644 --- a/api/v1alpha1/project_types.go +++ b/api/v1alpha1/project_types.go @@ -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 @@ -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. diff --git a/api/v1alpha1/workflowjob_types.go b/api/v1alpha1/workflowjob_types.go index 4b52315..90a5470 100644 --- a/api/v1alpha1/workflowjob_types.go +++ b/api/v1alpha1/workflowjob_types.go @@ -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. @@ -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 @@ -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 @@ -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` diff --git a/api/v1alpha1/workflowrun_types.go b/api/v1alpha1/workflowrun_types.go index e1b8c7c..c53f940 100644 --- a/api/v1alpha1/workflowrun_types.go +++ b/api/v1alpha1/workflowrun_types.go @@ -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 diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 575fed2..52b8aba 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -10,6 +10,36 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EnvironmentProtection) DeepCopyInto(out *EnvironmentProtection) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvironmentProtection. +func (in *EnvironmentProtection) DeepCopy() *EnvironmentProtection { + if in == nil { + return nil + } + out := new(EnvironmentProtection) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EnvironmentSecretReference) DeepCopyInto(out *EnvironmentSecretReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvironmentSecretReference. +func (in *EnvironmentSecretReference) DeepCopy() *EnvironmentSecretReference { + if in == nil { + return nil + } + out := new(EnvironmentSecretReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitHubAppConfiguration) DeepCopyInto(out *GitHubAppConfiguration) { *out = *in @@ -260,6 +290,31 @@ func (in *Project) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectEnvironment) DeepCopyInto(out *ProjectEnvironment) { + *out = *in + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(EnvironmentSecretReference) + **out = **in + } + if in.Protection != nil { + in, out := &in.Protection, &out.Protection + *out = new(EnvironmentProtection) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectEnvironment. +func (in *ProjectEnvironment) DeepCopy() *ProjectEnvironment { + if in == nil { + return nil + } + out := new(ProjectEnvironment) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ProjectList) DeepCopyInto(out *ProjectList) { *out = *in @@ -316,6 +371,13 @@ func (in *ProjectSource) DeepCopy() *ProjectSource { func (in *ProjectSpec) DeepCopyInto(out *ProjectSpec) { *out = *in in.Source.DeepCopyInto(&out.Source) + if in.Environments != nil { + in, out := &in.Environments, &out.Environments + *out = make([]ProjectEnvironment, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectSpec. @@ -580,6 +642,31 @@ func (in *WorkflowJob) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowJobEnvironment) DeepCopyInto(out *WorkflowJobEnvironment) { + *out = *in + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(EnvironmentSecretReference) + **out = **in + } + if in.Protection != nil { + in, out := &in.Protection, &out.Protection + *out = new(EnvironmentProtection) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowJobEnvironment. +func (in *WorkflowJobEnvironment) DeepCopy() *WorkflowJobEnvironment { + if in == nil { + return nil + } + out := new(WorkflowJobEnvironment) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkflowJobList) DeepCopyInto(out *WorkflowJobList) { *out = *in @@ -648,6 +735,11 @@ func (in *WorkflowJobSpec) DeepCopyInto(out *WorkflowJobSpec) { *out = new(WorkflowJobMatrix) (*in).DeepCopyInto(*out) } + if in.Environment != nil { + in, out := &in.Environment, &out.Environment + *out = new(WorkflowJobEnvironment) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowJobSpec. diff --git a/cmd/open-actions-runner/main.go b/cmd/open-actions-runner/main.go index a4c3732..13f6b5d 100644 --- a/cmd/open-actions-runner/main.go +++ b/cmd/open-actions-runner/main.go @@ -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 { @@ -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, diff --git a/cmd/open-actions-runner/main_test.go b/cmd/open-actions-runner/main_test.go index 152c3ad..b757c18 100644 --- a/cmd/open-actions-runner/main_test.go +++ b/cmd/open-actions-runner/main_test.go @@ -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) @@ -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) + } +} diff --git a/cmd/open-actions/run_command.go b/cmd/open-actions/run_command.go index dbce0cc..982f6a5 100644 --- a/cmd/open-actions/run_command.go +++ b/cmd/open-actions/run_command.go @@ -376,7 +376,7 @@ 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 @@ -384,13 +384,18 @@ func writeWorkflowRun(writer io.Writer, run *actionsv1alpha1.WorkflowRun, jobs [ 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), diff --git a/config/samples/actions_v1alpha1_project-environment.yaml b/config/samples/actions_v1alpha1_project-environment.yaml new file mode 100644 index 0000000..65ce05b --- /dev/null +++ b/config/samples/actions_v1alpha1_project-environment.yaml @@ -0,0 +1,24 @@ +apiVersion: actions.kelos.dev/v1alpha1 +kind: Project +metadata: + name: environment-example + namespace: open-actions +spec: + source: + type: GitHub + github: + appID: 12345 + installationID: 67890 + privateKeySecretRef: + name: open-actions-github-app + key: private-key.pem + webhookSecretRef: + name: open-actions-github-app + key: webhook-secret + workflowDirectory: .open-actions/workflows + environments: + - name: ok-to-test + secretRef: + name: open-actions-e2e-secrets + protection: + requiredApproval: true diff --git a/config/samples/actions_v1alpha1_workflowjob.yaml b/config/samples/actions_v1alpha1_workflowjob.yaml index 4b679ca..c9a723a 100644 --- a/config/samples/actions_v1alpha1_workflowjob.yaml +++ b/config/samples/actions_v1alpha1_workflowjob.yaml @@ -11,3 +11,9 @@ spec: displayName: Build and test runsOn: - ubuntu-latest + environment: + name: ok-to-test + secretRef: + name: open-actions-e2e-secrets + protection: + requiredApproval: true diff --git a/docs/reference.md b/docs/reference.md index 598ffac..72146ed 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -116,6 +116,71 @@ Runner labels are canonical lowercase ASCII in Kubernetes resources. Workflow execution slot and accepts one queued `WorkflowJob` from its `spec.projectRef` whose `runs-on` labels are all present in `spec.labels`. +### Environments and environment secrets + +`Project.spec.environments` is the allow-list of environments that workflows +may select. Names contain 1 to 255 characters, are matched without regard to +ASCII case, and must be unique under that comparison. A workflow job may use +either GitHub form: + +```yaml +environment: staging +``` + +```yaml +environment: + name: ${{ inputs.environment-name }} + url: https://deploy.example/${{ matrix.arch }} +``` + +The name and optional URL accept the planning contexts `github`, `inputs`, and +`matrix`. The evaluated name must match a configured Project environment; +otherwise planning fails. The controller writes the configured spelling, +optional URL, Secret reference, and protection policy to the immutable +`WorkflowJob.spec.environment`. This prevents an expression or untrusted +workflow input from selecting an unconfigured secret source or bypassing the +configured policy. + +An environment's optional `secretRef` selects one Kubernetes Secret in the +Project namespace. Its data keys populate the `secrets` expression context only +for jobs assigned to that environment. Keys follow GitHub secret naming rules, +are unique ignoring ASCII case, may not start with `GITHUB_`, and are limited to +100 entries and 8,192 UTF-8 bytes per value. `secrets.GITHUB_TOKEN` remains the +job's repository-scoped installation token. The controller copies environment +values into a job-owned Secret only after the approval gate and Runner +assignment, mounts it only in that job's runner container, masks the values in +runner output, and deletes the copy when the job is finalized. Environment +secrets are not stored in the ConfigMap job plan or `WorkflowJob.status`. + +Open Actions does not import or evaluate GitHub Environment protection rules. +Its Kubernetes-native equivalent is +`Project.spec.environments[].protection.requiredApproval`. When enabled, every +expanded WorkflowJob remains in `Waiting for approval` and is excluded from the +Runner queue. An operator with Kubernetes permission to update WorkflowJobs +approves that exact immutable job by setting this annotation to its environment +name: + +```console +kubectl annotate workflowjob WORKFLOW_JOB \ + --namespace PROJECT_NAMESPACE \ + actions.kelos.dev/environment-approved=ok-to-test +``` + +Kubernetes authorization and audit logging define who may perform this action; +do not grant untrusted workflow identities permission to update WorkflowJobs. +Approval applies only to that WorkflowJob, so a new delivery, revision, or +matrix child requires its own approval. Removing the annotation before Runner +assignment revokes the gate. After assignment, delete the WorkflowJob or its +WorkflowRun to cancel it. + +The `EnvironmentApproved` condition records `ApprovalRequired`, +`EnvironmentApproved`, or `ApprovalNotRequired`. The WorkflowRun job summary +counts `waitingForApproval` separately from `queued`, and the Console and GitHub +Check show the selected environments and approval state. +See +[`config/samples/actions_v1alpha1_project-environment.yaml`](../config/samples/actions_v1alpha1_project-environment.yaml) +for a complete Project example. + ### Docker execution `spec.execution.docker` enables a job-scoped Docker daemon. Its required @@ -164,7 +229,7 @@ The resources expose these condition contracts: | Resource | Condition | Status | Reasons | | --- | --- | --- | --- | | `Project` | `Configured` | `True` | `ConfigurationValid` | -| `Project` | `Configured` | `False` | `DuplicateInstallation`, `CredentialsUnavailable`, `InvalidCredentials` | +| `Project` | `Configured` | `False` | `DuplicateInstallation`, `CredentialsUnavailable`, `InvalidCredentials`, `EnvironmentInvalid`, `EnvironmentSecretsUnavailable` | | `Runner` | `Ready` | `True` | `Ready` | | `Runner` | `Ready` | `False` | `ProjectUnavailable`, `ProjectNotConfigured` | | `Runner` | `Busy` | `False` | `Idle` | @@ -172,11 +237,13 @@ The resources expose these condition contracts: | `WorkflowRun` | `Planned` | `True` | `JobsPlanned` | | `WorkflowRun` | `Planned` | `Unknown` | `WaitingForConcurrency`, `WaitingForConcurrencyCancellation`, `ProjectUnavailable`, `CredentialsUnavailable`, `GitHubAuthenticationFailed`, `WorkflowFetchFailed`, `ChildCreationFailed`, `ConcurrencyCheckFailed` | | `WorkflowRun` | `Planned` | `False` | `ProjectUnavailable`, `WorkflowFetchFailed`, `WorkflowInvalid`, `TriggerInvalid`, `ChildCreationFailed`, `ExecutionStateLost` | -| `WorkflowRun` | `Succeeded` | `Unknown` | `JobsQueued`, `JobsRunning` | +| `WorkflowRun` | `Succeeded` | `Unknown` | `JobsWaitingForApproval`, `JobsQueued`, `JobsRunning` | | `WorkflowRun` | `Succeeded` | `True` | `JobsSucceeded` | | `WorkflowRun` | `Succeeded` | `False` | `ProjectUnavailable`, `WorkflowFetchFailed`, `WorkflowInvalid`, `TriggerInvalid`, `ChildCreationFailed`, `JobFailed`, `ExecutionStateLost` | | `WorkflowJob` | `Scheduled` | `True` | `RunnerAssigned` | | `WorkflowJob` | `Scheduled` | `False` | `ProjectRecreated` | +| `WorkflowJob` | `EnvironmentApproved` | `True` | `EnvironmentApproved`, `ApprovalNotRequired` | +| `WorkflowJob` | `EnvironmentApproved` | `False` | `ApprovalRequired` | | `WorkflowJob` | `Succeeded` | `Unknown` | `JobRunning` | | `WorkflowJob` | `Succeeded` | `True` | `JobSucceeded` | | `WorkflowJob` | `Succeeded` | `False` | `JobFailed`, `JobResultInvalid`, `PlanUnavailable`, `JobStartFailed`, `ExecutionStateLost`, `ProjectRecreated` | @@ -205,6 +272,9 @@ distinguished from the original object. Workflow jobs that declare outputs, and their native Jobs and Pods, carry the `actions.kelos.dev/runner-result-version` annotation. Its value identifies the runner result format required to complete that job. +The `actions.kelos.dev/environment-approved` annotation is the operator-owned +approval input for protected environments; workflow definitions cannot set +resource metadata on their controller-owned WorkflowJobs. Webhook-created WorkflowRuns use the workflow filename followed by a stable 20-character digest of the project, delivery replay, and workflow path. @@ -247,8 +317,9 @@ evaluation when the corresponding execution feature has not supplied it. | --- | --- | --- | | Workflow concurrency | `github`, `inputs`, `vars` | `github`, `inputs` | | Job name and runner labels | `github`, `needs`, `strategy`, `matrix`, `vars`, `inputs` | `github`, `inputs`, and `matrix` for matrix jobs | -| Job environment | `github`, `needs`, `strategy`, `matrix`, `vars`, `secrets`, `inputs` | `github`, `inputs`, and `matrix` for matrix jobs | -| Workflow step name, run script, working directory, environment, and inputs | `github`, `needs`, `strategy`, `matrix`, `job`, `runner`, `env`, `vars`, `secrets`, `steps`, `inputs` | `github`, `matrix`, `runner`, `env`, `inputs`, `steps` | +| Environment name and URL | `github`, `needs`, `strategy`, `matrix`, `vars`, `inputs` | `github`, `inputs`, and `matrix` for matrix jobs | +| Job `env` values | `github`, `needs`, `strategy`, `matrix`, `vars`, `secrets`, `inputs` | `github`, `inputs`, `secrets`, and `matrix` for matrix jobs | +| Workflow step name, run script, working directory, environment, and inputs | `github`, `needs`, `strategy`, `matrix`, `job`, `runner`, `env`, `vars`, `secrets`, `steps`, `inputs` | `github`, `matrix`, `runner`, `env`, `inputs`, `secrets`, and `steps` | | Workflow step condition | Step contexts except `secrets`, plus status functions | `github`, `matrix`, `runner`, `env`, `inputs`, `steps`, and status functions | | Job outputs | Workflow step contexts | `github`, `matrix`, `runner`, `env`, `inputs`, `steps` | | Composite step fields and outputs | `github`, `runner`, `env`, `inputs`, `steps` | All listed contexts | @@ -256,7 +327,9 @@ evaluation when the corresponding execution feature has not supplied it. | Action input default | `github` | `github` | Dependency scheduling and its `needs` context, and repository secret and -variable sources remain separate execution features. Values derived +variable sources remain separate execution features. The `secrets` context +contains the selected environment's Kubernetes Secret plus the automatic +`GITHUB_TOKEN`; it does not merge repository or organization secrets. Values derived from `github.token` or the `secrets` context are marked sensitive through interpolation and function calls, and evaluation diagnostics do not include resolved values. The runner maps interrupt and termination signals to cancelled @@ -461,8 +534,8 @@ reusable inputs, the selected cron expression, and revision fields used by the supported event. Actions that require other fields from GitHub's raw webhook payload are not supported. -The controller emits job-plan version 4, and the runner accepts versions 1 -through 4. When a release changes the job-plan version, update every Runner +The controller emits job-plan version 5, and the runner accepts versions 1 +through 5. When a release changes the job-plan version, update every Runner `spec.execution.image` to an image that accepts both the installed and target controller versions before upgrading the controller. The received job-plan version also determines the runner result version. A runner that accepts more diff --git a/internal/apischema/crd_test.go b/internal/apischema/crd_test.go index 6002ef5..9b8c6db 100644 --- a/internal/apischema/crd_test.go +++ b/internal/apischema/crd_test.go @@ -546,6 +546,13 @@ func TestCRDConventions(t *testing.T) { t.Error("spec.execution.docker.image is not required") } } + if tt.kind == "Project" { + validateSample(t, crd, "actions_v1alpha1_project-environment.yaml") + environments := spec.Properties["environments"] + if environments.MaxItems == nil || *environments.MaxItems != 100 || environments.XListType == nil || *environments.XListType != "map" || !slices.Contains(environments.XListMapKeys, "name") { + t.Errorf("spec.environments schema = %#v", environments) + } + } if tt.kind == "WorkflowJob" { outputs := status.Properties["outputs"] if outputs.MaxProperties == nil || *outputs.MaxProperties != 100 { @@ -554,6 +561,10 @@ func TestCRDConventions(t *testing.T) { if len(outputs.XValidations) != 2 { t.Errorf("status.outputs validation rules = %d, want 2", len(outputs.XValidations)) } + environment := spec.Properties["environment"] + if !slices.Contains(environment.Required, "name") { + t.Error("spec.environment.name is not required") + } } if tt.kind == "WorkflowRun" { ttl := spec.Properties["ttlSecondsAfterFinished"] @@ -563,6 +574,10 @@ func TestCRDConventions(t *testing.T) { if ttl.Format != "int32" || ttl.Minimum == nil || *ttl.Minimum != 0 || ttl.Maximum == nil || *ttl.Maximum != 2147483647 { t.Errorf("spec.ttlSecondsAfterFinished schema = %#v", ttl) } + waiting := status.Properties["jobs"].Properties["waitingForApproval"] + if waiting.Minimum == nil || *waiting.Minimum != 0 || waiting.Maximum == nil || *waiting.Maximum != 100000 { + t.Errorf("status.jobs.waitingForApproval schema = %#v", waiting) + } } }) } @@ -597,6 +612,30 @@ func TestCRDRejectsInvalidCELValues(t *testing.T) { object["spec"].(map[string]any)["workflowDirectory"] = ".open-actions/workflows/" }, }, + { + name: "Project environment secret reference with empty DNS label", + crd: "actions.kelos.dev_projects.yaml", sample: "actions_v1alpha1_project-environment.yaml", + mutate: func(object map[string]any) { + environments := object["spec"].(map[string]any)["environments"].([]any) + environments[0].(map[string]any)["secretRef"].(map[string]any)["name"] = "invalid..name" + }, + }, + { + name: "Project environment name with control character", + crd: "actions.kelos.dev_projects.yaml", sample: "actions_v1alpha1_project-environment.yaml", + mutate: func(object map[string]any) { + environments := object["spec"].(map[string]any)["environments"].([]any) + environments[0].(map[string]any)["name"] = "invalid\nenvironment" + }, + }, + { + name: "Project environment names duplicated with different case", + crd: "actions.kelos.dev_projects.yaml", sample: "actions_v1alpha1_project-environment.yaml", + mutate: func(object map[string]any) { + environments := object["spec"].(map[string]any)["environments"].([]any) + object["spec"].(map[string]any)["environments"] = append(environments, map[string]any{"name": "OK-TO-TEST"}) + }, + }, { name: "Runner project reference with long DNS label", crd: "actions.kelos.dev_runners.yaml", sample: "actions_v1alpha1_runner.yaml", @@ -744,6 +783,13 @@ func TestCRDRejectsInvalidCELValues(t *testing.T) { object["status"] = map[string]any{"outputs": map[string]any{"invalid.name": "value"}} }, }, + { + name: "WorkflowJob environment secret reference with empty DNS label", + crd: "actions.kelos.dev_workflowjobs.yaml", sample: "actions_v1alpha1_workflowjob.yaml", + mutate: func(object map[string]any) { + object["spec"].(map[string]any)["environment"].(map[string]any)["secretRef"].(map[string]any)["name"] = "invalid..name" + }, + }, { name: "WorkflowJob output exceeding value bound", crd: "actions.kelos.dev_workflowjobs.yaml", sample: "actions_v1alpha1_workflowjob.yaml", diff --git a/internal/console/handler.go b/internal/console/handler.go index a1fe93f..b3dd699 100644 --- a/internal/console/handler.go +++ b/internal/console/handler.go @@ -105,6 +105,7 @@ type runPageData struct { type jobPageData struct { ID string DisplayName string + Environment string Runner string Status string StatusClass string @@ -121,6 +122,7 @@ type logPageData struct { Status string JobName string Runner string + Environment string Duration string RunURL string StreamURL string @@ -372,6 +374,9 @@ func (h *Handler) loadRunPageData(ctx context.Context, run *actionsv1alpha1.Work for index := range jobs.Items { job := &jobs.Items[index] item := jobPageData{ID: job.Spec.JobID, DisplayName: job.Spec.DisplayName, Status: workflowstatus.Job(job), URL: runPath(run) + "/jobs/" + url.PathEscape(job.Name)} + if job.Spec.Environment != nil { + item.Environment = job.Spec.Environment.Name + } if item.DisplayName == "" { item.DisplayName = item.ID } @@ -409,13 +414,20 @@ func (h *Handler) jobLogs(writer http.ResponseWriter, request *http.Request, run runData.Jobs[index].Selected = runData.Jobs[index].ID == job.Spec.JobID } runnerName := "Waiting for a runner" + environmentName := "" + if job.Spec.Environment != nil { + environmentName = job.Spec.Environment.Name + if jobStatus == "Waiting for approval" { + runnerName = "Environment approval required" + } + } if job.Status.RunnerRef != nil { runnerName = job.Status.RunnerRef.Name } h.writeHTML(writer, h.logPage, logPageData{ Repository: runData.Repository, WorkflowName: runData.WorkflowName, ShortRevision: runData.ShortRevision, Status: jobStatus, - JobName: displayName, Runner: runnerName, Duration: elapsedTime(job.Status.StartTime, job.Status.CompletionTime), + JobName: displayName, Runner: runnerName, Environment: environmentName, Duration: elapsedTime(job.Status.StartTime, job.Status.CompletionTime), RunURL: path, StreamURL: path + "/jobs/" + url.PathEscape(job.Name) + "/stream", Jobs: runData.Jobs, }) } @@ -586,7 +598,7 @@ func (h *Handler) waitForPod(ctx context.Context, job *actionsv1alpha1.WorkflowJ } func statusClass(status string) string { - return strings.ToLower(status) + return strings.ReplaceAll(strings.ToLower(status), " ", "-") } func shortRevision(revision string) string { diff --git a/internal/console/handler_test.go b/internal/console/handler_test.go index e1307af..4e2cbce 100644 --- a/internal/console/handler_test.go +++ b/internal/console/handler_test.go @@ -13,6 +13,7 @@ import ( actionsv1alpha1 "github.com/kelos-dev/open-actions/api/v1alpha1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -72,7 +73,7 @@ func TestConsoleAuthenticatesWithStaticTokenAndStreamsLogs(t *testing.T) { runRequest.AddCookie(sessionCookie) runResponse := httptest.NewRecorder() handler.ServeHTTP(runResponse, runRequest) - if runResponse.Code != http.StatusOK || !strings.Contains(runResponse.Body.String(), "CI") || !strings.Contains(runResponse.Body.String(), "build") || !strings.Contains(runResponse.Body.String(), "Workflow run Queued") { + if runResponse.Code != http.StatusOK || !strings.Contains(runResponse.Body.String(), "CI") || !strings.Contains(runResponse.Body.String(), "build") || !strings.Contains(runResponse.Body.String(), "Workflow run Queued") || !strings.Contains(runResponse.Body.String(), "ok-to-test") || !strings.Contains(runResponse.Body.String(), "Waiting for approval") { t.Fatalf("run page = %d, %q", runResponse.Code, runResponse.Body.String()) } @@ -302,8 +303,12 @@ func newTestHandler(t *testing.T, secureCookie bool) *Handler { job := &actionsv1alpha1.WorkflowJob{ TypeMeta: metav1.TypeMeta{APIVersion: actionsv1alpha1.GroupVersion.String(), Kind: "WorkflowJob"}, ObjectMeta: metav1.ObjectMeta{Name: "build", Namespace: "default", UID: "job-uid", Labels: map[string]string{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}}, - Spec: actionsv1alpha1.WorkflowJobSpec{WorkflowRunRef: corev1.LocalObjectReference{Name: run.Name}, JobID: "build"}, + Spec: actionsv1alpha1.WorkflowJobSpec{ + WorkflowRunRef: corev1.LocalObjectReference{Name: run.Name}, JobID: "build", + Environment: &actionsv1alpha1.WorkflowJobEnvironment{Name: "ok-to-test"}, + }, } + meta.SetStatusCondition(&job.Status.Conditions, metav1.Condition{Type: actionsv1alpha1.WorkflowJobConditionEnvironmentApproved, Status: metav1.ConditionFalse, Reason: "ApprovalRequired"}) if err := controllerutil.SetControllerReference(run, job, scheme); err != nil { t.Fatal(err) } diff --git a/internal/console/templates.go b/internal/console/templates.go index e55d7ab..b1e95bb 100644 --- a/internal/console/templates.go +++ b/internal/console/templates.go @@ -69,7 +69,7 @@ const runPageTemplate = ` {{.WorkflowName}} · Open Actions @@ -90,7 +90,7 @@ const runPageTemplate = `
{{range .Jobs}}
{{.DisplayName}}{{if ne .DisplayName .ID}}{{.ID}}{{end}} - {{.Status}}{{if .Runner}}{{.Runner}}{{else}}—{{end}}{{if .Duration}}{{.Duration}}{{else if .Started}}In progress{{else}}Not started{{end}} + {{if .Environment}}{{.Environment}}{{else}}—{{end}}{{.Status}}{{if .Runner}}{{.Runner}}{{else}}—{{end}}{{if .Duration}}{{.Duration}}{{else if .Started}}In progress{{else}}Not started{{end}}
{{else}}
No jobs have been created.
{{end}}
@@ -105,7 +105,7 @@ const logPageTemplate = ` {{.JobName}} · {{.WorkflowName}} · Open Actions @@ -120,7 +120,7 @@ const logPageTemplate = `
-

{{.JobName}}

{{.Status}}{{.Runner}}{{if .Duration}}{{.Duration}}{{end}}{{.ShortRevision}}
+

{{.JobName}}

{{.Status}}{{if .Environment}}Environment: {{.Environment}}{{end}}{{.Runner}}{{if .Duration}}{{.Duration}}{{end}}{{.ShortRevision}}
Connecting…
diff --git a/internal/controller/project_controller.go b/internal/controller/project_controller.go index 736ae29..c75526c 100644 --- a/internal/controller/project_controller.go +++ b/internal/controller/project_controller.go @@ -3,10 +3,12 @@ package controller import ( "context" "fmt" + "strings" "time" actionsv1alpha1 "github.com/kelos-dev/open-actions/api/v1alpha1" githubclient "github.com/kelos-dev/open-actions/internal/github" + corev1 "k8s.io/api/core/v1" apiEquality "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -64,13 +66,28 @@ func (r *ProjectReconciler) validate(ctx context.Context, project *actionsv1alph github := project.Spec.Source.GitHub privateKey, err := secretValue(ctx, r.APIReader, project.Namespace, github.PrivateKeySecretRef) if err != nil { - return "CredentialsUnavailable", err + return "CredentialsUnavailable", fmt.Errorf("validate Project %q credentials: %w", project.Name, err) } if err := githubclient.ValidatePrivateKey(privateKey); err != nil { - return "InvalidCredentials", fmt.Errorf("validate GitHub App private key: %w", err) + return "InvalidCredentials", fmt.Errorf("validate Project %q GitHub App private key: %w", project.Name, err) } if _, err := secretValue(ctx, r.APIReader, project.Namespace, github.WebhookSecretRef); err != nil { - return "CredentialsUnavailable", err + return "CredentialsUnavailable", fmt.Errorf("validate Project %q credentials: %w", project.Name, err) + } + environments := make(map[string]string, len(project.Spec.Environments)) + for index := range project.Spec.Environments { + environment := &project.Spec.Environments[index] + canonicalName := strings.ToLower(environment.Name) + if other := environments[canonicalName]; other != "" { + return "EnvironmentInvalid", fmt.Errorf("Project %q contains case-insensitive duplicate environments %q and %q", project.Name, other, environment.Name) + } + environments[canonicalName] = environment.Name + if environment.SecretRef != nil && environment.SecretRef.Name == "" { + return "EnvironmentInvalid", fmt.Errorf("Project %q environment %q has an empty secretRef name", project.Name, environment.Name) + } + if _, err := environmentSecretValues(ctx, r.APIReader, project.Namespace, environment.SecretRef); err != nil { + return "EnvironmentSecretsUnavailable", fmt.Errorf("validate Project %q environment %q: %w", project.Name, environment.Name, err) + } } return "", nil } @@ -110,9 +127,31 @@ func (r *ProjectReconciler) SetupWithManager(manager ctrl.Manager) error { return ctrl.NewControllerManagedBy(manager). For(&actionsv1alpha1.Project{}). Watches(&actionsv1alpha1.Project{}, handler.EnqueueRequestsFromMapFunc(r.projectsForInstallation)). + Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.projectsForSecret)). Complete(r) } +func (r *ProjectReconciler) projectsForSecret(ctx context.Context, object client.Object) []reconcile.Request { + projects := &actionsv1alpha1.ProjectList{} + if err := r.List(ctx, projects, client.InNamespace(object.GetNamespace())); err != nil { + return nil + } + requests := []reconcile.Request{} + for index := range projects.Items { + project := &projects.Items[index] + github := project.Spec.Source.GitHub + matched := github != nil && (github.PrivateKeySecretRef.Name == object.GetName() || github.WebhookSecretRef.Name == object.GetName()) + for environmentIndex := range project.Spec.Environments { + reference := project.Spec.Environments[environmentIndex].SecretRef + matched = matched || reference != nil && reference.Name == object.GetName() + } + if matched { + requests = append(requests, requestFor(project)) + } + } + return requests +} + func (r *ProjectReconciler) projectsForInstallation(ctx context.Context, object client.Object) []reconcile.Request { project, ok := object.(*actionsv1alpha1.Project) if !ok || project.Spec.Source.GitHub == nil { diff --git a/internal/controller/project_controller_test.go b/internal/controller/project_controller_test.go index c8c53e0..71ebcb9 100644 --- a/internal/controller/project_controller_test.go +++ b/internal/controller/project_controller_test.go @@ -6,6 +6,7 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" + "strings" "testing" "time" @@ -40,7 +41,7 @@ func TestProjectConfiguredConditionDescribesLocalValidation(t *testing.T) { PrivateKeySecretRef: corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "github"}, Key: "private-key"}, WebhookSecretRef: corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "github"}, Key: "webhook-secret"}, }, - }}, + }, Environments: []actionsv1alpha1.ProjectEnvironment{{Name: "production", SecretRef: &actionsv1alpha1.EnvironmentSecretReference{Name: "production-secrets"}}}}, } secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: "github", Namespace: "default"}, @@ -49,7 +50,8 @@ func TestProjectConfiguredConditionDescribesLocalValidation(t *testing.T) { "webhook-secret": []byte("secret"), }, } - clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&actionsv1alpha1.Project{}).WithObjects(project, secret).Build() + environmentSecret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "production-secrets", Namespace: "default"}, Data: map[string][]byte{"DEPLOY_TOKEN": []byte("secret")}} + clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&actionsv1alpha1.Project{}).WithObjects(project, secret, environmentSecret).Build() reconciler := &ProjectReconciler{Client: clusterClient, APIReader: clusterClient} if _, err := reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "default"}}); err != nil { t.Fatal(err) @@ -64,6 +66,19 @@ func TestProjectConfiguredConditionDescribesLocalValidation(t *testing.T) { } } +func TestProjectRejectsInvalidEnvironmentSecrets(t *testing.T) { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "environment", Namespace: "default"}, Data: map[string][]byte{"GITHUB_TOKEN": []byte("override")}} + clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build() + _, err := environmentSecretValues(context.Background(), clusterClient, "default", &actionsv1alpha1.EnvironmentSecretReference{Name: secret.Name}) + if err == nil || !strings.Contains(err.Error(), `invalid GitHub secret name "GITHUB_TOKEN"`) { + t.Fatalf("environmentSecretValues() error = %v", err) + } +} + func TestEarlierProjectOwnsInstallationAcrossNamespaces(t *testing.T) { scheme := runtime.NewScheme() if err := actionsv1alpha1.AddToScheme(scheme); err != nil { diff --git a/internal/controller/runner_controller.go b/internal/controller/runner_controller.go index 57fe92d..c8bb715 100644 --- a/internal/controller/runner_controller.go +++ b/internal/controller/runner_controller.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/binary" + "encoding/json" "errors" "fmt" "sort" @@ -33,10 +34,13 @@ var ( const ( jobPlanVolume = "open-actions-job" + jobCredentialsVolume = "open-actions-credentials" workspaceVolume = "open-actions-workspace" dockerSocketVolume = "open-actions-docker-socket" dockerStorageVolume = "open-actions-docker-storage" jobPlanMountPath = "/var/run/open-actions" + jobCredentialsMountPath = "/var/run/open-actions-credentials" + jobSecretsKey = "secrets.json" workspaceVolumeMountPath = "/workspace" jobResultPath = "/dev/termination-log" dockerSocketDirectory = "/var/run/open-actions-docker" @@ -391,6 +395,9 @@ func (r *RunnerReconciler) executeWorkflowJob(ctx context.Context, runnerObject if workflowJob.Labels[actionsv1alpha1.LabelProjectUID] != string(project.UID) { return true, r.failAssignedWorkflowJob(ctx, workflowJob, "ProjectRecreated", fmt.Sprintf("Project %q was recreated before execution started", project.Name)) } + if !workflowJobEnvironmentApproved(workflowJob) { + return false, fmt.Errorf("WorkflowJob %q environment is not approved", workflowJob.Name) + } if workflowJobStarted(workflowJob) { active, err := r.activeWorkflowJobPods(ctx, workflowJob) if err != nil { @@ -427,7 +434,15 @@ func (r *RunnerReconciler) executeWorkflowJob(ctx context.Context, runnerObject } else if canceled { return true, r.cancelWorkflowJob(ctx, workflowJob) } - if err := r.ensureAuthSecret(ctx, workflowJob, installation.Token()); err != nil { + var secretsReference *actionsv1alpha1.EnvironmentSecretReference + if workflowJob.Spec.Environment != nil { + secretsReference = workflowJob.Spec.Environment.SecretRef + } + environmentSecrets, err := environmentSecretValues(ctx, r.APIReader, project.Namespace, secretsReference) + if err != nil { + return false, fmt.Errorf("read environment secrets for WorkflowJob %q: %w", workflowJob.Name, err) + } + if err := r.ensureAuthSecret(ctx, workflowJob, installation.Token(), environmentSecrets); err != nil { return false, err } nativeJob, err := r.buildJob(workflowJob, run, project, runnerObject) @@ -615,7 +630,11 @@ func (r *RunnerReconciler) cleanupAuthSecret(ctx context.Context, workflowJob *a return nil } -func (r *RunnerReconciler) ensureAuthSecret(ctx context.Context, workflowJob *actionsv1alpha1.WorkflowJob, token string) error { +func (r *RunnerReconciler) ensureAuthSecret(ctx context.Context, workflowJob *actionsv1alpha1.WorkflowJob, token string, environmentSecrets map[string]string) error { + secretsData, err := json.Marshal(environmentSecrets) + if err != nil { + return fmt.Errorf("encode environment secrets for WorkflowJob %q: %w", workflowJob.Name, err) + } secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ Name: childName(workflowJob.Name, "auth"), Namespace: workflowJob.Namespace, @@ -623,7 +642,7 @@ func (r *RunnerReconciler) ensureAuthSecret(ctx context.Context, workflowJob *ac actionsv1alpha1.LabelWorkflowRunUID: workflowJob.Labels[actionsv1alpha1.LabelWorkflowRunUID], actionsv1alpha1.LabelWorkflowJobUID: string(workflowJob.UID), }, - }, Data: map[string][]byte{"token": []byte(token)}} + }, Data: map[string][]byte{"token": []byte(token), jobSecretsKey: secretsData}} if err := controllerutil.SetControllerReference(workflowJob, secret, r.Scheme()); err != nil { return err } @@ -651,6 +670,7 @@ func (r *RunnerReconciler) ensureAuthSecret(ctx context.Context, workflowJob *ac existing.Data = map[string][]byte{} } existing.Data["token"] = []byte(token) + existing.Data[jobSecretsKey] = secretsData if apiEquality.Semantic.DeepEqual(before, existing) { return nil } @@ -703,11 +723,13 @@ func (r *RunnerReconciler) buildJob(workflowJob *actionsv1alpha1.WorkflowJob, ru }}, VolumeMounts: []corev1.VolumeMount{ {Name: jobPlanVolume, MountPath: jobPlanMountPath, ReadOnly: true}, + {Name: jobCredentialsVolume, MountPath: jobCredentialsMountPath, ReadOnly: true}, {Name: workspaceVolume, MountPath: workspaceVolumeMountPath}, }, }}, Volumes: []corev1.Volume{ {Name: jobPlanVolume, VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{LocalObjectReference: corev1.LocalObjectReference{Name: childName(workflowJob.Name, "plan")}}}}, + {Name: jobCredentialsVolume, VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: childName(workflowJob.Name, "auth")}}}, {Name: workspaceVolume, VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, }, }, @@ -997,12 +1019,19 @@ func (r *RunnerReconciler) runnersForWorkflowRun(ctx context.Context, object cli func indexQueuedWorkflowJob(object client.Object) []string { workflowJob := object.(*actionsv1alpha1.WorkflowJob) - if workflowJob.DeletionTimestamp.IsZero() && workflowJob.Status.RunnerRef == nil && !terminalWorkflowJob(workflowJob) { + if workflowJob.DeletionTimestamp.IsZero() && workflowJob.Status.RunnerRef == nil && !terminalWorkflowJob(workflowJob) && workflowJobEnvironmentApproved(workflowJob) { return []string{"true"} } return nil } +func workflowJobEnvironmentApproved(workflowJob *actionsv1alpha1.WorkflowJob) bool { + if workflowJob.Spec.Environment == nil { + return true + } + return meta.IsStatusConditionTrue(workflowJob.Status.Conditions, actionsv1alpha1.WorkflowJobConditionEnvironmentApproved) +} + func indexWorkflowJobRunnerName(object client.Object) []string { workflowJob := object.(*actionsv1alpha1.WorkflowJob) if workflowJob.Status.RunnerRef == nil { diff --git a/internal/controller/runner_controller_test.go b/internal/controller/runner_controller_test.go index c75088c..afe08ec 100644 --- a/internal/controller/runner_controller_test.go +++ b/internal/controller/runner_controller_test.go @@ -2,6 +2,7 @@ package controller import ( "context" + "encoding/json" "errors" "fmt" "slices" @@ -115,7 +116,7 @@ func TestRunnerBuildsOwnedJob(t *testing.T) { container.SecurityContext.Capabilities == nil || !slices.Equal(container.SecurityContext.Capabilities.Drop, []corev1.Capability{"ALL"}) { t.Errorf("container security context = %#v", container.SecurityContext) } - if len(job.Spec.Template.Spec.Volumes) != 2 { + if len(job.Spec.Template.Spec.Volumes) != 3 { t.Errorf("volumes = %#v", job.Spec.Template.Spec.Volumes) } } @@ -178,6 +179,7 @@ func TestRunnerBuildsDockerEnabledJob(t *testing.T) { } expectedRunnerMounts := []corev1.VolumeMount{ {Name: jobPlanVolume, MountPath: jobPlanMountPath, ReadOnly: true}, + {Name: jobCredentialsVolume, MountPath: jobCredentialsMountPath, ReadOnly: true}, {Name: workspaceVolume, MountPath: workspaceVolumeMountPath}, {Name: dockerSocketVolume, MountPath: dockerSocketDirectory}, } @@ -518,6 +520,26 @@ func TestDeletingWorkflowJobIsNotQueued(t *testing.T) { } } +func TestEnvironmentApprovalControlsWorkflowJobQueue(t *testing.T) { + workflowJob := &actionsv1alpha1.WorkflowJob{Spec: actionsv1alpha1.WorkflowJobSpec{ + Environment: &actionsv1alpha1.WorkflowJobEnvironment{ + Name: "ok-to-test", Protection: &actionsv1alpha1.EnvironmentProtection{RequiredApproval: true}, + }, + }} + meta.SetStatusCondition(&workflowJob.Status.Conditions, metav1.Condition{ + Type: actionsv1alpha1.WorkflowJobConditionEnvironmentApproved, Status: metav1.ConditionFalse, Reason: "ApprovalRequired", + }) + if values := indexQueuedWorkflowJob(workflowJob); len(values) != 0 { + t.Fatalf("unapproved WorkflowJob queue index = %v", values) + } + meta.SetStatusCondition(&workflowJob.Status.Conditions, metav1.Condition{ + Type: actionsv1alpha1.WorkflowJobConditionEnvironmentApproved, Status: metav1.ConditionTrue, Reason: "EnvironmentApproved", + }) + if values := indexQueuedWorkflowJob(workflowJob); !slices.Equal(values, []string{"true"}) { + t.Fatalf("approved WorkflowJob queue index = %v", values) + } +} + func TestTerminalWorkflowJobStatusIsDurableBeforeCredentialCleanup(t *testing.T) { scheme := runnerTestScheme(t) workflowJob := &actionsv1alpha1.WorkflowJob{ @@ -1119,7 +1141,7 @@ func TestEnsureAuthSecretRejectsUnownedCollision(t *testing.T) { } clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workflowJob, secret).Build() reconciler := &RunnerReconciler{Client: clusterClient, APIReader: clusterClient} - if err := reconciler.ensureAuthSecret(context.Background(), workflowJob, "replacement"); err == nil { + if err := reconciler.ensureAuthSecret(context.Background(), workflowJob, "replacement", nil); err == nil { t.Fatal("unowned authentication Secret was accepted") } stored := &corev1.Secret{} @@ -1131,6 +1153,30 @@ func TestEnsureAuthSecretRejectsUnownedCollision(t *testing.T) { } } +func TestEnsureAuthSecretStoresEnvironmentSecretsSeparatelyFromToken(t *testing.T) { + scheme := runnerTestScheme(t) + workflowJob := &actionsv1alpha1.WorkflowJob{ + TypeMeta: metav1.TypeMeta{APIVersion: actionsv1alpha1.GroupVersion.String(), Kind: "WorkflowJob"}, + ObjectMeta: metav1.ObjectMeta{Name: "build", Namespace: "default", UID: types.UID("job-uid")}, + } + clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workflowJob).Build() + reconciler := &RunnerReconciler{Client: clusterClient, APIReader: clusterClient} + if err := reconciler.ensureAuthSecret(context.Background(), workflowJob, "installation-token", map[string]string{"DEPLOY_TOKEN": "environment-token"}); err != nil { + t.Fatal(err) + } + stored := &corev1.Secret{} + if err := clusterClient.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: childName(workflowJob.Name, "auth")}, stored); err != nil { + t.Fatal(err) + } + secrets := map[string]string{} + if err := json.Unmarshal(stored.Data[jobSecretsKey], &secrets); err != nil { + t.Fatal(err) + } + if string(stored.Data["token"]) != "installation-token" || secrets["DEPLOY_TOKEN"] != "environment-token" { + t.Fatalf("authentication Secret data = %#v, secrets = %#v", stored.Data, secrets) + } +} + func TestDeletingBusyRunnerFinalizesItsWorkflowJob(t *testing.T) { scheme := runnerTestScheme(t) deletionTime := metav1.Now() diff --git a/internal/controller/secrets.go b/internal/controller/secrets.go index c6546c7..3b75233 100644 --- a/internal/controller/secrets.go +++ b/internal/controller/secrets.go @@ -3,11 +3,23 @@ package controller import ( "context" "fmt" + "regexp" + "sort" + "strings" + "unicode/utf8" + actionsv1alpha1 "github.com/kelos-dev/open-actions/api/v1alpha1" corev1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) +const ( + maxEnvironmentSecrets = 100 + maxEnvironmentSecretBytes = 8 << 10 +) + +var environmentSecretNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + func secretValue(ctx context.Context, reader client.Reader, namespace string, selector corev1.SecretKeySelector) ([]byte, error) { secret := &corev1.Secret{} if err := reader.Get(ctx, client.ObjectKey{Namespace: namespace, Name: selector.Name}, secret); err != nil { @@ -19,3 +31,39 @@ func secretValue(ctx context.Context, reader client.Reader, namespace string, se } return value, nil } + +func environmentSecretValues(ctx context.Context, reader client.Reader, namespace string, reference *actionsv1alpha1.EnvironmentSecretReference) (map[string]string, error) { + if reference == nil { + return map[string]string{}, nil + } + secret := &corev1.Secret{} + if err := reader.Get(ctx, client.ObjectKey{Namespace: namespace, Name: reference.Name}, secret); err != nil { + return nil, fmt.Errorf("get environment Secret %q: %w", reference.Name, err) + } + if len(secret.Data) > maxEnvironmentSecrets { + return nil, fmt.Errorf("environment Secret %q contains %d keys; maximum is %d", secret.Name, len(secret.Data), maxEnvironmentSecrets) + } + names := make([]string, 0, len(secret.Data)) + for name := range secret.Data { + names = append(names, name) + } + sort.Strings(names) + values := make(map[string]string, len(names)) + canonicalNames := make(map[string]string, len(names)) + for _, name := range names { + value := secret.Data[name] + canonical := strings.ToUpper(name) + if !environmentSecretNamePattern.MatchString(name) || strings.HasPrefix(canonical, "GITHUB_") { + return nil, fmt.Errorf("environment Secret %q contains invalid GitHub secret name %q", secret.Name, name) + } + if other := canonicalNames[canonical]; other != "" { + return nil, fmt.Errorf("environment Secret %q contains case-insensitive duplicate keys %q and %q", secret.Name, other, name) + } + if len(value) > maxEnvironmentSecretBytes || !utf8.Valid(value) { + return nil, fmt.Errorf("environment Secret %q value %q must be valid UTF-8 no larger than %d bytes", secret.Name, name, maxEnvironmentSecretBytes) + } + canonicalNames[canonical] = name + values[name] = string(value) + } + return values, nil +} diff --git a/internal/controller/workflowrun_controller.go b/internal/controller/workflowrun_controller.go index b42cb42..7a12022 100644 --- a/internal/controller/workflowrun_controller.go +++ b/internal/controller/workflowrun_controller.go @@ -284,7 +284,11 @@ func (r *WorkflowRunReconciler) reconcileGitHubCheck(ctx context.Context, run *a if !r.githubCheckEnabled(run) { return nil } - report := workflowRunCheckReport(run) + jobs := &actionsv1alpha1.WorkflowJobList{} + if err := r.APIReader.List(ctx, jobs, client.InNamespace(run.Namespace), client.MatchingLabels{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}); err != nil { + return fmt.Errorf("list WorkflowJobs for WorkflowRun %q GitHub check: %w", run.Name, err) + } + report := workflowRunCheckReportWithJobs(run, jobs.Items) project := &actionsv1alpha1.Project{} projectKey := client.ObjectKey{Namespace: run.Namespace, Name: run.Spec.ProjectRef.Name} if err := r.APIReader.Get(ctx, projectKey, project); err != nil { @@ -376,6 +380,10 @@ func checkRunReportDigest(request githubclient.CreateCheckRunRequest) string { } func workflowRunCheckReport(run *actionsv1alpha1.WorkflowRun) checkRunReport { + return workflowRunCheckReportWithJobs(run, nil) +} + +func workflowRunCheckReportWithJobs(run *actionsv1alpha1.WorkflowRun, workflowJobs []actionsv1alpha1.WorkflowJob) checkRunReport { title := run.Status.WorkflowName if title == "" { title = run.Spec.WorkflowPath @@ -422,11 +430,59 @@ func workflowRunCheckReport(run *actionsv1alpha1.WorkflowRun) checkRunReport { } if run.Status.Jobs != nil { jobs := run.Status.Jobs - report.Output.Text = fmt.Sprintf("Jobs: %d total, %d queued, %d active, %d succeeded, %d failed.", jobs.Total, jobs.Queued, jobs.Active, jobs.Succeeded, jobs.Failed) + report.Output.Text = fmt.Sprintf("Jobs: %d total, %d waiting for approval, %d queued, %d active, %d succeeded, %d failed.", jobs.Total, jobs.WaitingForApproval, jobs.Queued, jobs.Active, jobs.Succeeded, jobs.Failed) + } + if environmentText := checkRunEnvironmentText(workflowJobs); environmentText != "" { + if report.Output.Text != "" { + report.Output.Text += "\n\n" + } + report.Output.Text += environmentText } return report } +func checkRunEnvironmentText(workflowJobs []actionsv1alpha1.WorkflowJob) string { + type environmentState struct { + name string + required bool + waiting bool + } + states := map[string]environmentState{} + for index := range workflowJobs { + job := &workflowJobs[index] + if job.Spec.Environment == nil { + continue + } + key := strings.ToLower(job.Spec.Environment.Name) + state := states[key] + state.name = job.Spec.Environment.Name + state.required = state.required || job.Spec.Environment.Protection != nil && job.Spec.Environment.Protection.RequiredApproval + state.waiting = state.waiting || !workflowJobEnvironmentApproved(job) + states[key] = state + } + if len(states) == 0 { + return "" + } + keys := make([]string, 0, len(states)) + for key := range states { + keys = append(keys, key) + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, key := range keys { + state := states[key] + approval := "approval not required" + if state.required { + approval = "approved" + } + if state.waiting { + approval = "waiting for approval" + } + lines = append(lines, fmt.Sprintf("Environment %q: %s.", state.name, approval)) + } + return strings.Join(lines, "\n") +} + func (r *checkRunReport) summaryFromCondition(condition *metav1.Condition) { if condition.Message != "" { r.Output.Summary = condition.Message @@ -466,6 +522,7 @@ type plannedWorkflowJob struct { displayName string runsOn []string matrix *actionsv1alpha1.WorkflowJobMatrix + environment *workflow.JobEnvironment plan string resultVersion string } @@ -486,6 +543,10 @@ func (r *WorkflowRunReconciler) ensureWorkflowJobs(ctx context.Context, run *act for _, item := range plannedJobs { id := item.id + environment, err := workflowJobEnvironment(project, item.environment) + if err != nil { + return &terminalPlanningError{cause: fmt.Errorf("job %q: %w", id, err)} + } labels := workflowJobLabels(run, project, id) annotations := map[string]string{actionsv1alpha1.AnnotationProjectName: project.Name} if item.resultVersion != "" { @@ -504,6 +565,7 @@ func (r *WorkflowRunReconciler) ensureWorkflowJobs(ctx context.Context, run *act DisplayName: item.displayName, RunsOn: append([]string(nil), item.runsOn...), Matrix: item.matrix.DeepCopy(), + Environment: environment, }, } if err := controllerutil.SetControllerReference(run, workflowJob, r.Scheme()); err != nil { @@ -531,10 +593,71 @@ func (r *WorkflowRunReconciler) ensureWorkflowJobs(ctx context.Context, run *act if err := r.ensurePlanConfigMap(ctx, workflowJob, item.plan); err != nil { return err } + if err := r.reconcileEnvironmentApproval(ctx, workflowJob); err != nil { + return err + } } return nil } +func workflowJobEnvironment(project *actionsv1alpha1.Project, requested *workflow.JobEnvironment) (*actionsv1alpha1.WorkflowJobEnvironment, error) { + if requested == nil { + return nil, nil + } + var matched *actionsv1alpha1.ProjectEnvironment + for index := range project.Spec.Environments { + configured := &project.Spec.Environments[index] + if !strings.EqualFold(configured.Name, requested.Name) { + continue + } + if matched != nil { + return nil, fmt.Errorf("environment %q is configured more than once on Project %q", requested.Name, project.Name) + } + matched = configured + } + if matched != nil { + return &actionsv1alpha1.WorkflowJobEnvironment{ + Name: matched.Name, URL: requested.URL, + SecretRef: matched.SecretRef.DeepCopy(), Protection: matched.Protection.DeepCopy(), + }, nil + } + return nil, fmt.Errorf("environment %q is not configured on Project %q", requested.Name, project.Name) +} + +func (r *WorkflowRunReconciler) reconcileEnvironmentApproval(ctx context.Context, workflowJob *actionsv1alpha1.WorkflowJob) error { + environment := workflowJob.Spec.Environment + if environment == nil { + return nil + } + required := environment.Protection != nil && environment.Protection.RequiredApproval + approved := !required || strings.EqualFold(workflowJob.Annotations[actionsv1alpha1.AnnotationEnvironmentApproved], environment.Name) + current := meta.FindStatusCondition(workflowJob.Status.Conditions, actionsv1alpha1.WorkflowJobConditionEnvironmentApproved) + if workflowJob.Status.RunnerRef != nil && current != nil && current.Status == metav1.ConditionTrue { + approved = true + } + status := metav1.ConditionFalse + reason := "ApprovalRequired" + message := fmt.Sprintf("Environment %q requires approval", environment.Name) + if approved { + status = metav1.ConditionTrue + reason = "EnvironmentApproved" + message = fmt.Sprintf("Environment %q was approved", environment.Name) + if !required { + reason = "ApprovalNotRequired" + message = fmt.Sprintf("Environment %q does not require approval", environment.Name) + } + } + before := workflowJob.Status.DeepCopy() + meta.SetStatusCondition(&workflowJob.Status.Conditions, metav1.Condition{ + Type: actionsv1alpha1.WorkflowJobConditionEnvironmentApproved, Status: status, + ObservedGeneration: workflowJob.Generation, Reason: reason, Message: message, + }) + if apiEquality.Semantic.DeepEqual(before, &workflowJob.Status) { + return nil + } + return r.Status().Update(ctx, workflowJob) +} + func (r *WorkflowRunReconciler) planWorkflowJobs(run *actionsv1alpha1.WorkflowRun, definition *workflow.Definition, inputValues map[string]any) ([]plannedWorkflowJob, error) { jobIDs := make([]string, 0, len(definition.Jobs)) for id := range definition.Jobs { @@ -601,7 +724,8 @@ func (r *WorkflowRunReconciler) planWorkflowJobs(run *actionsv1alpha1.WorkflowRu resultVersion = jobResultVersion } plannedJobs = append(plannedJobs, plannedWorkflowJob{ - id: expandedID, displayName: displayName, runsOn: append([]string(nil), resolvedJob.RunsOn...), matrix: matrixSpec, plan: string(data), resultVersion: resultVersion, + id: expandedID, displayName: displayName, runsOn: append([]string(nil), resolvedJob.RunsOn...), matrix: matrixSpec, + environment: resolvedJob.Environment, plan: string(data), resultVersion: resultVersion, }) } } @@ -1017,6 +1141,9 @@ func (r *WorkflowRunReconciler) observeWorkflowJobs(ctx context.Context, run *ac } for index := range jobs.Items { job := &jobs.Items[index] + if err := r.reconcileEnvironmentApproval(ctx, job); err != nil { + return ctrl.Result{}, err + } condition := meta.FindStatusCondition(job.Status.Conditions, actionsv1alpha1.WorkflowJobConditionSucceeded) if condition == nil || (condition.Status != metav1.ConditionTrue && condition.Status != metav1.ConditionFalse) { plan := &corev1.ConfigMap{} @@ -1052,6 +1179,8 @@ func (r *WorkflowRunReconciler) observeWorkflowJobs(ctx context.Context, run *ac status.Failed++ case job.Status.RunnerRef != nil: status.Active++ + case !workflowJobEnvironmentApproved(job): + status.WaitingForApproval++ default: status.Queued++ } @@ -1110,6 +1239,8 @@ func (r *WorkflowRunReconciler) observeWorkflowJobs(ctx context.Context, run *ac now := metav1.Now() run.Status.CompletionTime = &now meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{Type: actionsv1alpha1.WorkflowRunConditionSucceeded, Status: metav1.ConditionTrue, ObservedGeneration: run.Generation, Reason: "JobsSucceeded", Message: "All WorkflowJobs succeeded"}) + case status.WaitingForApproval > 0: + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{Type: actionsv1alpha1.WorkflowRunConditionSucceeded, Status: metav1.ConditionUnknown, ObservedGeneration: run.Generation, Reason: "JobsWaitingForApproval", Message: "WorkflowJobs are waiting for environment approval"}) case status.Queued > 0: meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{Type: actionsv1alpha1.WorkflowRunConditionSucceeded, Status: metav1.ConditionUnknown, ObservedGeneration: run.Generation, Reason: "JobsQueued", Message: "WorkflowJobs are waiting for matching Runners"}) default: diff --git a/internal/controller/workflowrun_controller_test.go b/internal/controller/workflowrun_controller_test.go index a9283e1..845d8aa 100644 --- a/internal/controller/workflowrun_controller_test.go +++ b/internal/controller/workflowrun_controller_test.go @@ -22,6 +22,7 @@ import ( "github.com/kelos-dev/open-actions/internal/workflow" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" + apiEquality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -523,6 +524,22 @@ func TestWorkflowRunCheckReportMapsLifecycle(t *testing.T) { } } +func TestWorkflowRunCheckReportIncludesEnvironmentApproval(t *testing.T) { + run := &actionsv1alpha1.WorkflowRun{Status: actionsv1alpha1.WorkflowRunStatus{Jobs: &actionsv1alpha1.WorkflowRunJobStatus{Total: 1, WaitingForApproval: 1}}} + job := actionsv1alpha1.WorkflowJob{Spec: actionsv1alpha1.WorkflowJobSpec{Environment: &actionsv1alpha1.WorkflowJobEnvironment{ + Name: "ok-to-test", Protection: &actionsv1alpha1.EnvironmentProtection{RequiredApproval: true}, + }}} + report := workflowRunCheckReportWithJobs(run, []actionsv1alpha1.WorkflowJob{job}) + if !strings.Contains(report.Output.Text, "1 waiting for approval") || !strings.Contains(report.Output.Text, `Environment "ok-to-test": waiting for approval.`) { + t.Fatalf("check output = %q", report.Output.Text) + } + meta.SetStatusCondition(&job.Status.Conditions, metav1.Condition{Type: actionsv1alpha1.WorkflowJobConditionEnvironmentApproved, Status: metav1.ConditionTrue, Reason: "EnvironmentApproved"}) + report = workflowRunCheckReportWithJobs(run, []actionsv1alpha1.WorkflowJob{job}) + if !strings.Contains(report.Output.Text, `Environment "ok-to-test": approved.`) { + t.Fatalf("approved check output = %q", report.Output.Text) + } +} + func TestCompletedWorkflowRunTTL(t *testing.T) { now := time.Date(2026, time.August, 10, 12, 0, 0, 0, time.UTC) completedAt := metav1.NewTime(now.Add(-2 * time.Hour)) @@ -1021,6 +1038,81 @@ func TestEnsureWorkflowJobsCreatesReadableNames(t *testing.T) { } } +func TestEnsureWorkflowJobsFreezesEnvironmentAndRequiresApproval(t *testing.T) { + scheme := runtime.NewScheme() + if err := actionsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + run := &actionsv1alpha1.WorkflowRun{ObjectMeta: metav1.ObjectMeta{Name: "deploy", Namespace: "default", UID: "run-uid"}} + project := &actionsv1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{Name: "project", Namespace: "default", UID: "project-uid"}, + Spec: actionsv1alpha1.ProjectSpec{Environments: []actionsv1alpha1.ProjectEnvironment{{ + Name: "ok-to-test", SecretRef: &actionsv1alpha1.EnvironmentSecretReference{Name: "e2e-secrets"}, + Protection: &actionsv1alpha1.EnvironmentProtection{RequiredApproval: true}, + }}}, + } + clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&actionsv1alpha1.WorkflowJob{}).WithObjects(run, project).Build() + reconciler := &WorkflowRunReconciler{Client: clusterClient, APIReader: clusterClient} + planned := []plannedWorkflowJob{{ + id: "e2e", displayName: "E2E", runsOn: []string{"linux"}, plan: "{}", + environment: &workflow.JobEnvironment{Name: "OK-TO-TEST", URL: "https://deploy.example/e2e"}, + }} + if err := reconciler.ensureWorkflowJobs(context.Background(), run, project, planned); err != nil { + t.Fatal(err) + } + job := &actionsv1alpha1.WorkflowJob{} + if err := clusterClient.Get(context.Background(), client.ObjectKey{Namespace: run.Namespace, Name: workflowJobName(run.Name, "e2e")}, job); err != nil { + t.Fatal(err) + } + if job.Spec.Environment == nil || job.Spec.Environment.Name != "ok-to-test" || job.Spec.Environment.URL != "https://deploy.example/e2e" || job.Spec.Environment.SecretRef == nil || job.Spec.Environment.SecretRef.Name != "e2e-secrets" { + t.Fatalf("WorkflowJob environment = %#v", job.Spec.Environment) + } + approved := meta.FindStatusCondition(job.Status.Conditions, actionsv1alpha1.WorkflowJobConditionEnvironmentApproved) + if approved == nil || approved.Status != metav1.ConditionFalse || approved.Reason != "ApprovalRequired" { + t.Fatalf("environment approval condition = %#v", approved) + } + beforeSpec := job.Spec.DeepCopy() + if job.Annotations == nil { + job.Annotations = map[string]string{} + } + job.Annotations[actionsv1alpha1.AnnotationEnvironmentApproved] = "ok-to-test" + if err := clusterClient.Update(context.Background(), job); err != nil { + t.Fatal(err) + } + if err := reconciler.reconcileEnvironmentApproval(context.Background(), job); err != nil { + t.Fatal(err) + } + if !meta.IsStatusConditionTrue(job.Status.Conditions, actionsv1alpha1.WorkflowJobConditionEnvironmentApproved) { + t.Fatalf("environment approval condition = %#v", job.Status.Conditions) + } + if !apiEquality.Semantic.DeepEqual(beforeSpec, &job.Spec) { + t.Fatalf("approval changed immutable WorkflowJob spec: %#v", job.Spec) + } +} + +func TestEnsureWorkflowJobsRejectsUnconfiguredEnvironment(t *testing.T) { + scheme := runtime.NewScheme() + if err := actionsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + run := &actionsv1alpha1.WorkflowRun{ObjectMeta: metav1.ObjectMeta{Name: "deploy", Namespace: "default", UID: "run-uid"}} + project := &actionsv1alpha1.Project{ObjectMeta: metav1.ObjectMeta{Name: "project", Namespace: "default", UID: "project-uid"}} + clusterClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(run, project).Build() + reconciler := &WorkflowRunReconciler{Client: clusterClient, APIReader: clusterClient} + err := reconciler.ensureWorkflowJobs(context.Background(), run, project, []plannedWorkflowJob{{ + id: "deploy", runsOn: []string{"linux"}, plan: "{}", environment: &workflow.JobEnvironment{Name: "production"}, + }}) + if err == nil || !strings.Contains(err.Error(), `environment "production" is not configured on Project "project"`) { + t.Fatalf("ensureWorkflowJobs() error = %v", err) + } +} + func TestEnsureWorkflowJobsPreservesMatrixIdentity(t *testing.T) { scheme := runtime.NewScheme() if err := actionsv1alpha1.AddToScheme(scheme); err != nil { @@ -1191,6 +1283,48 @@ func TestPlannedWorkflowRunIsObservedWithoutPlanningDependencies(t *testing.T) { } } +func TestPlannedWorkflowRunReportsEnvironmentApprovalWait(t *testing.T) { + scheme := runtime.NewScheme() + if err := actionsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + run := &actionsv1alpha1.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{Name: "ci", Namespace: "default", UID: types.UID("run-uid")}, + Status: actionsv1alpha1.WorkflowRunStatus{ + WorkflowName: "CI", Jobs: &actionsv1alpha1.WorkflowRunJobStatus{Total: 1}, + Conditions: []metav1.Condition{plannedCondition(metav1.ConditionTrue, "JobsPlanned")}, + }, + } + job := &actionsv1alpha1.WorkflowJob{ + ObjectMeta: metav1.ObjectMeta{Name: "e2e", Namespace: "default", UID: types.UID("job-uid"), Labels: map[string]string{actionsv1alpha1.LabelWorkflowRunUID: string(run.UID)}}, + Spec: actionsv1alpha1.WorkflowJobSpec{Environment: &actionsv1alpha1.WorkflowJobEnvironment{ + Name: "ok-to-test", Protection: &actionsv1alpha1.EnvironmentProtection{RequiredApproval: true}, + }}, + } + plan := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: childName(job.Name, "plan"), Namespace: job.Namespace}} + if err := controllerutil.SetControllerReference(job, plan, scheme); err != nil { + t.Fatal(err) + } + clusterClient := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&actionsv1alpha1.WorkflowRun{}, &actionsv1alpha1.WorkflowJob{}). + WithObjects(run, job, plan).Build() + reconciler := &WorkflowRunReconciler{Client: clusterClient, APIReader: clusterClient} + if _, err := reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(run)}); err != nil { + t.Fatal(err) + } + stored := &actionsv1alpha1.WorkflowRun{} + if err := clusterClient.Get(context.Background(), client.ObjectKeyFromObject(run), stored); err != nil { + t.Fatal(err) + } + condition := meta.FindStatusCondition(stored.Status.Conditions, actionsv1alpha1.WorkflowRunConditionSucceeded) + if stored.Status.Jobs == nil || stored.Status.Jobs.WaitingForApproval != 1 || stored.Status.Jobs.Queued != 0 || condition == nil || condition.Reason != "JobsWaitingForApproval" { + t.Fatalf("WorkflowRun status = %#v", stored.Status) + } +} + func TestPlannedWorkflowRunFailsWhenAChildIsMissing(t *testing.T) { scheme := runtime.NewScheme() if err := actionsv1alpha1.AddToScheme(scheme); err != nil { diff --git a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_projects.yaml b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_projects.yaml index 4b9bbe5..7fe0030 100644 --- a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_projects.yaml +++ b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_projects.yaml @@ -53,6 +53,55 @@ spec: description: ProjectSpec describes the workflow source for an Open Actions Project. properties: + environments: + description: |- + 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. + items: + description: |- + ProjectEnvironment configures one workflow environment. Environment names + are matched without regard to ASCII case. + properties: + name: + description: Name is the GitHub-compatible environment name + selected by a workflow job. + maxLength: 255 + minLength: 1 + pattern: ^[^\x00-\x1f\x7f]+$ + type: string + protection: + description: Protection configures the gate enforced before + a job can be assigned. + properties: + requiredApproval: + description: |- + RequiredApproval requires an authorized user to approve each WorkflowJob + before a Runner can claim it. + type: boolean + type: object + secretRef: + description: |- + SecretRef identifies a Secret in the Project namespace whose data keys + populate the secrets expression context for jobs in this environment. + properties: + name: + description: Name is the Secret resource name. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?([.][a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?)*$ + type: string + required: + - name + type: object + required: + - name + type: object + maxItems: 100 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map source: description: Source selects and configures the external workflow source. properties: @@ -202,6 +251,10 @@ spec: required: - source type: object + x-kubernetes-validations: + - message: environment names must be unique ignoring ASCII case + rule: '!has(self.environments) || self.environments.all(e, self.environments.exists_one(other, + e.name.lowerAscii() == other.name.lowerAscii()))' status: description: ProjectStatus contains observations made by the project controller. properties: diff --git a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowjobs.yaml b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowjobs.yaml index 57f6c2f..03fc494 100644 --- a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowjobs.yaml +++ b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowjobs.yaml @@ -25,6 +25,12 @@ spec: - jsonPath: .status.runnerRef.name name: Runner type: string + - jsonPath: .spec.environment.name + name: Environment + type: string + - jsonPath: .status.conditions[?(@.type=="EnvironmentApproved")].status + name: Approved + type: string - jsonPath: .status.conditions[?(@.type=="Scheduled")].status name: Scheduled type: string @@ -71,6 +77,50 @@ spec: maxLength: 256 minLength: 1 type: string + environment: + description: |- + Environment contains the selected workflow environment and the Project + policy resolved for this job. + properties: + name: + description: Name is the configured Project environment name. + maxLength: 255 + minLength: 1 + pattern: ^[^\x00-\x1f\x7f]+$ + type: string + protection: + description: Protection is the Project environment gate resolved + for this job. + properties: + requiredApproval: + description: |- + RequiredApproval requires an authorized user to approve each WorkflowJob + before a Runner can claim it. + type: boolean + type: object + secretRef: + description: |- + SecretRef identifies the Project Secret whose data keys are available to + this job through the secrets expression context. + properties: + name: + description: Name is the Secret resource name. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?([.][a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?)*$ + type: string + required: + - name + type: object + url: + description: URL is the optional deployment target URL resolved + from the workflow. + maxLength: 2048 + minLength: 1 + type: string + required: + - name + type: object jobID: description: |- JobID is the stable workflow-local identifier of this expanded job. For a @@ -170,9 +220,10 @@ spec: type: string conditions: description: |- - 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. items: description: Condition contains details for one aspect of the current state of this API Resource. diff --git a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowruns.yaml b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowruns.yaml index 1358dd6..e57f995 100644 --- a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowruns.yaml +++ b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowruns.yaml @@ -731,6 +731,14 @@ spec: maximum: 100000 minimum: 0 type: integer + waitingForApproval: + description: |- + WaitingForApproval is the number of jobs blocked by an environment + approval gate and therefore not yet queued for a Runner. + format: int32 + maximum: 100000 + minimum: 0 + type: integer type: object observedGeneration: description: |- diff --git a/internal/runner/expression.go b/internal/runner/expression.go index 6140bef..1738c2d 100644 --- a/internal/runner/expression.go +++ b/internal/runner/expression.go @@ -10,19 +10,20 @@ import ( ) var ( - runnerJobAvailability = workflowexpression.NewAvailability("github", "matrix", "inputs") - runnerStepAvailability = workflowexpression.NewAvailability("github", "matrix", "runner", "env", "inputs", "steps") + runnerJobAvailability = workflowexpression.NewAvailability("github", "matrix", "inputs", "secrets") + runnerStepAvailability = workflowexpression.NewAvailability("github", "matrix", "runner", "env", "inputs", "secrets", "steps") runnerConditionAvailability = workflowexpression.NewAvailability("github", "matrix", "runner", "env", "inputs", "steps").WithStatusFunctions() compositeAvailability = workflowexpression.NewAvailability("github", "runner", "env", "inputs", "steps") compositeConditionAvailability = workflowexpression.NewAvailability("github", "runner", "env", "inputs", "steps").WithStatusFunctions() + actionDefaultAvailability = workflowexpression.NewAvailability("github", "matrix", "inputs") ) -func resolveJobEnvironment(values map[string]string, plan *Plan, environment []string, token string) (map[string]string, error) { - return resolveExpressionMap(values, expressionContext(plan, environment, "", nil, runnerJobAvailability, nil, token)) +func resolveJobEnvironment(values map[string]string, plan *Plan, environment []string, token string, secrets map[string]string) (map[string]string, error) { + return resolveExpressionMap(values, expressionContext(plan, environment, "", nil, runnerJobAvailability, nil, token, secrets)) } func resolveActionDefaultExpression(input string, plan *Plan, environment []string, token string) (string, error) { - context := expressionContext(plan, environment, "", nil, runnerJobAvailability, nil, token) + context := expressionContext(plan, environment, "", nil, actionDefaultAvailability, nil, token, nil) return resolveExpressionString(input, context) } @@ -77,7 +78,7 @@ func workflowStepCondition(input string, environment map[string]string, status w func workflowExpressionContext(state *executionState, environment []string, availability workflowexpression.Availability, status *workflowexpression.Status) workflowexpression.Context { values := map[string]any{"steps": state.stepOutputs} - return expressionContext(state.plan, environment, "", values, availability, status, state.githubToken) + return expressionContext(state.plan, environment, "", values, availability, status, state.githubToken, state.secrets) } func compositeExpressionContext(compositeContext *compositeContext, availability workflowexpression.Availability, status *workflowexpression.Status) workflowexpression.Context { @@ -90,10 +91,10 @@ func compositeExpressionContext(compositeContext *compositeContext, availability "inputs": compositeContext.inputs, "steps": steps, } - return expressionContext(compositeContext.state.plan, environment, compositeContext.actionPath, values, availability, status, compositeContext.state.githubToken) + return expressionContext(compositeContext.state.plan, environment, compositeContext.actionPath, values, availability, status, compositeContext.state.githubToken, nil) } -func expressionContext(plan *Plan, environment []string, actionPath string, extra map[string]any, availability workflowexpression.Availability, status *workflowexpression.Status, token string) workflowexpression.Context { +func expressionContext(plan *Plan, environment []string, actionPath string, extra map[string]any, availability workflowexpression.Availability, status *workflowexpression.Status, token string, secrets map[string]string) workflowexpression.Context { pullRequestRefs := planPullRequestRefs(plan) github := map[string]any{ "workflow": plan.WorkflowName, @@ -125,6 +126,12 @@ func expressionContext(plan *Plan, environment []string, actionPath string, extr }, "env": environmentContext(environment), } + secretValues := make(map[string]any, len(secrets)+1) + for name, value := range secrets { + secretValues[name] = workflowexpression.Secret(value) + } + secretValues["GITHUB_TOKEN"] = workflowexpression.Secret(token) + values["secrets"] = secretValues for name, value := range extra { values[name] = value } diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 095da60..a58f75b 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -23,7 +23,7 @@ import ( const ( minimumPlanVersion = 1 - PlanVersion = 4 + PlanVersion = 5 ContainerName = "runner" ) @@ -120,6 +120,7 @@ type Step struct { type ExecutorConfig struct { Logger *slog.Logger GitHubToken string + Secrets map[string]string Environment []string Stdout io.Writer Stderr io.Writer @@ -128,6 +129,7 @@ type ExecutorConfig struct { type Executor struct { logger *slog.Logger githubToken string + secrets map[string]string environment []string stdout io.Writer stderr io.Writer @@ -142,6 +144,7 @@ type executionState struct { temporaryDirectory string environment []string githubToken string + secrets map[string]string resolver *actionResolver posts []*actionInvocation compositeStack map[string]bool @@ -155,9 +158,18 @@ func NewExecutor(config ExecutorConfig) (*Executor, error) { } masker := newOutputMasker(config.GitHubToken) masker.add(base64.StdEncoding.EncodeToString([]byte("x-access-token:" + config.GitHubToken))) + secrets := make(map[string]string, len(config.Secrets)) + for name, value := range config.Secrets { + if len(value) > maxMaskValueBytes { + return nil, fmt.Errorf("workflow job secret %q exceeds %d bytes", name, maxMaskValueBytes) + } + secrets[name] = value + masker.add(value) + } return &Executor{ logger: config.Logger.With(runnerLogMarker, true), githubToken: config.GitHubToken, + secrets: secrets, environment: append([]string(nil), config.Environment...), stdout: config.Stdout, stderr: config.Stderr, @@ -166,6 +178,24 @@ func NewExecutor(config ExecutorConfig) (*Executor, error) { }, nil } +// LoadSecrets reads the environment-scoped secret values supplied separately +// from the non-secret workflow plan. +func LoadSecrets(path string) (map[string]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read workflow job secrets: %w", err) + } + secrets := map[string]string{} + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(&secrets); err != nil { + return nil, fmt.Errorf("decode workflow job secrets: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, errors.New("decode workflow job secrets: trailing JSON value") + } + return secrets, nil +} + func LoadPlan(path string) (*Plan, error) { data, err := os.ReadFile(path) if err != nil { @@ -264,7 +294,7 @@ func (e *Executor) executePlan(ctx context.Context, plan *Plan, workspace string "RUNNER_TEMP="+filepath.Join(temporaryDirectory, "temp"), "RUNNER_TOOL_CACHE="+filepath.Join(temporaryDirectory, "tool-cache"), ) - jobEnvironment, err := resolveJobEnvironment(plan.Env, plan, environment, e.githubToken) + jobEnvironment, err := resolveJobEnvironment(plan.Env, plan, environment, e.githubToken, e.secrets) if err != nil { return emptyResult, fmt.Errorf("resolve job environment: %w", err) } @@ -282,6 +312,7 @@ func (e *Executor) executePlan(ctx context.Context, plan *Plan, workspace string temporaryDirectory: temporaryDirectory, environment: environment, githubToken: e.githubToken, + secrets: e.secrets, resolver: newActionResolver(plan.Repository.ActionCloneBaseURL, filepath.Join(temporaryDirectory, "actions"), environment, e.executeCommand), compositeStack: map[string]bool{}, stepOutputs: map[string]map[string]any{}, diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 11530f1..1c86916 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -391,13 +391,50 @@ func TestResolvedStepBytesEnforcesFieldLimits(t *testing.T) { func TestExecuteRejectsUnavailableExpressionContext(t *testing.T) { plan := testPlan() - plan.Env = map[string]string{"TOKEN": "${{ secrets.TOKEN }}"} + plan.Steps[0].If = "${{ secrets.TOKEN }}" err := testExecutor(t, io.Discard, io.Discard).Execute(context.Background(), plan, t.TempDir()) if err == nil || !strings.Contains(err.Error(), `context "secrets" is unavailable`) { t.Fatalf("error = %v, want unavailable secrets context", err) } } +func TestExecuteResolvesAndMasksEnvironmentSecrets(t *testing.T) { + plan := testPlan() + plan.Env = map[string]string{ + "DEPLOY_TOKEN": "${{ secrets.DEPLOY_TOKEN }}", + "GITHUB_SECRET": "${{ secrets.GITHUB_TOKEN }}", + } + plan.Steps = []Step{{Run: `printf '%s %s\n' "$DEPLOY_TOKEN" "$GITHUB_SECRET"`}} + var output bytes.Buffer + executor, err := NewExecutor(ExecutorConfig{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), GitHubToken: "installation-token", + Secrets: map[string]string{"DEPLOY_TOKEN": "environment-token"}, Environment: os.Environ(), Stdout: &output, Stderr: &output, + }) + if err != nil { + t.Fatal(err) + } + if err := executor.Execute(context.Background(), plan, t.TempDir()); err != nil { + t.Fatal(err) + } + if strings.Contains(output.String(), "environment-token") || strings.Contains(output.String(), "installation-token") || !strings.Contains(output.String(), "***") { + t.Fatalf("secret output was not masked: %q", output.String()) + } +} + +func TestLoadSecrets(t *testing.T) { + path := filepath.Join(t.TempDir(), "secrets.json") + if err := os.WriteFile(path, []byte(`{"DEPLOY_TOKEN":"value"}`), 0o600); err != nil { + t.Fatal(err) + } + secrets, err := LoadSecrets(path) + if err != nil { + t.Fatal(err) + } + if secrets["DEPLOY_TOKEN"] != "value" { + t.Fatalf("secrets = %#v", secrets) + } +} + func TestLoadPlanSupportsCompatibleVersions(t *testing.T) { for version := minimumPlanVersion; version <= PlanVersion; version++ { t.Run(fmt.Sprintf("version %d", version), func(t *testing.T) { @@ -421,7 +458,7 @@ func TestExpressionContextsPreserveTriggerInputTypes(t *testing.T) { plan := testPlan() plan.Inputs = map[string]any{"enabled": false, "retries": float64(2)} plan.Event.Schedule = "0 6 * * *" - context := expressionContext(plan, nil, "", nil, runnerConditionAvailability, nil, "token") + context := expressionContext(plan, nil, "", nil, runnerConditionAvailability, nil, "token", nil) enabled, err := evaluateCondition("${{ inputs.enabled }}", context, true) if err != nil { t.Fatal(err) @@ -537,6 +574,16 @@ func TestNewExecutorRequiresGitHubToken(t *testing.T) { } } +func TestNewExecutorRejectsSecretTooLargeToMask(t *testing.T) { + _, err := NewExecutor(ExecutorConfig{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), GitHubToken: "token", + Secrets: map[string]string{"TOKEN": strings.Repeat("x", maxMaskValueBytes+1)}, Environment: os.Environ(), Stdout: io.Discard, Stderr: io.Discard, + }) + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("NewExecutor() error = %v", err) + } +} + func TestActionCloneTokenIsLimitedToWorkflowRepository(t *testing.T) { plan := testPlan() sameRepository := actionref.Reference{Owner: "ACME", Repository: "Example"} diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index d5283a1..5637375 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -27,6 +27,8 @@ const ( maxMatrixValueLength = 1024 maxJobIDLength = 256 maxJobNameLength = 256 + maxEnvironmentNameLength = 255 + maxEnvironmentURLLength = 2048 maxRunnerLabels = 16 maxSteps = 100 MaxStepIDLength = 256 @@ -121,16 +123,57 @@ func (schedule *Schedule) UnmarshalYAML(node *yaml.Node) error { } type Job struct { - Name string `yaml:"name"` - RunsOn StringList `yaml:"runs-on"` - Needs StringList `yaml:"needs"` - Outputs map[string]any `yaml:"outputs"` - Steps []Step `yaml:"steps"` - Strategy Strategy `yaml:"strategy"` - Container yaml.Node `yaml:"container"` - Services yaml.Node `yaml:"services"` - If string `yaml:"if"` - Env map[string]any `yaml:"env"` + Name string `yaml:"name"` + RunsOn StringList `yaml:"runs-on"` + Needs StringList `yaml:"needs"` + Outputs map[string]any `yaml:"outputs"` + Steps []Step `yaml:"steps"` + Strategy Strategy `yaml:"strategy"` + Container yaml.Node `yaml:"container"` + Services yaml.Node `yaml:"services"` + If string `yaml:"if"` + Env map[string]any `yaml:"env"` + Environment *JobEnvironment `yaml:"environment"` +} + +// JobEnvironment is the environment selected by a workflow job. +type JobEnvironment struct { + Name string `yaml:"name"` + URL string `yaml:"url"` + urlSet bool +} + +func (environment *JobEnvironment) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + if err := node.Decode(&environment.Name); err != nil { + return fmt.Errorf("environment must be a string or mapping") + } + return nil + case yaml.MappingNode: + if err := rejectDuplicateMappingKeys(node, "environment"); err != nil { + return err + } + for index := 0; index < len(node.Content); index += 2 { + name := node.Content[index].Value + switch name { + case "name": + if err := node.Content[index+1].Decode(&environment.Name); err != nil { + return fmt.Errorf("environment name must be a string") + } + case "url": + environment.urlSet = true + if err := node.Content[index+1].Decode(&environment.URL); err != nil { + return fmt.Errorf("environment url must be a string") + } + default: + return fmt.Errorf("unsupported environment field %q", name) + } + } + return nil + default: + return fmt.Errorf("environment must be a string or mapping") + } } type Strategy struct { @@ -208,6 +251,8 @@ type Repository struct { var ( workflowConcurrencyAvailability = expression.NewAvailability("github", "inputs", "vars") jobNameAvailability = expression.NewAvailability("github", "needs", "strategy", "matrix", "vars", "inputs") + jobEnvironmentNameAvailability = expression.NewAvailability("github", "needs", "strategy", "matrix", "vars", "inputs") + jobEnvironmentURLAvailability = expression.NewAvailability("github", "needs", "strategy", "matrix", "vars", "inputs") jobEnvironmentAvailability = expression.NewAvailability("github", "needs", "strategy", "matrix", "vars", "secrets", "inputs") stepAvailability = expression.NewAvailability("github", "needs", "strategy", "matrix", "job", "runner", "env", "vars", "secrets", "steps", "inputs") stepConditionAvailability = expression.NewAvailability("github", "needs", "strategy", "matrix", "job", "runner", "env", "vars", "steps", "inputs").WithStatusFunctions() @@ -287,6 +332,26 @@ func validateJob(id string, job *Job) error { if err := validateTemplate(fmt.Sprintf("job %q name", id), job.Name, jobNameAvailability); err != nil { return err } + if job.Environment != nil { + if job.Environment.Name == "" { + return fmt.Errorf("job %q environment name must not be empty", id) + } + if utf8.RuneCountInString(job.Environment.Name) > maxEnvironmentNameLength { + return fmt.Errorf("job %q environment name exceeds %d characters", id, maxEnvironmentNameLength) + } + if err := validateTemplate(fmt.Sprintf("job %q environment name", id), job.Environment.Name, jobEnvironmentNameAvailability); err != nil { + return err + } + if job.Environment.urlSet && job.Environment.URL == "" { + return fmt.Errorf("job %q environment url must not be empty", id) + } + if utf8.RuneCountInString(job.Environment.URL) > maxEnvironmentURLLength { + return fmt.Errorf("job %q environment url exceeds %d characters", id, maxEnvironmentURLLength) + } + if err := validateTemplate(fmt.Sprintf("job %q environment url", id), job.Environment.URL, jobEnvironmentURLAvailability); err != nil { + return err + } + } if len(job.RunsOn) == 0 { return fmt.Errorf("job %q must define runs-on", id) } @@ -583,6 +648,28 @@ func EvaluateJob(id string, job Job, context expression.Context) (Job, error) { if utf8.RuneCountInString(job.Name) > maxJobNameLength { return Job{}, fmt.Errorf("job %q evaluated name exceeds %d characters", id, maxJobNameLength) } + if job.Environment != nil { + environment := *job.Environment + name, err := evaluateTemplateString(environment.Name, context) + if err != nil { + return Job{}, fmt.Errorf("job %q environment name: %w", id, err) + } + if name == "" || utf8.RuneCountInString(name) > maxEnvironmentNameLength { + return Job{}, fmt.Errorf("job %q evaluated an invalid environment name", id) + } + environment.Name = name + if environment.URL != "" { + url, err := evaluateTemplateString(environment.URL, context) + if err != nil { + return Job{}, fmt.Errorf("job %q environment url: %w", id, err) + } + if url == "" || utf8.RuneCountInString(url) > maxEnvironmentURLLength { + return Job{}, fmt.Errorf("job %q evaluated an invalid environment url", id) + } + environment.URL = url + } + job.Environment = &environment + } job.RunsOn = append(StringList(nil), job.RunsOn...) labels := make(map[string]struct{}, len(job.RunsOn)) @@ -612,6 +699,18 @@ func EvaluateJob(id string, job Job, context expression.Context) (Job, error) { return job, nil } +func evaluateTemplateString(input string, context expression.Context) (string, error) { + program, err := expression.Parse(input) + if err != nil { + return "", err + } + result, err := program.Evaluate(context) + if err != nil { + return "", err + } + return result.String() +} + func EvaluateConcurrency(definition *Definition, event Event) (string, bool, error) { if definition.Concurrency.Group == "" { return "", false, nil diff --git a/internal/workflow/workflow_test.go b/internal/workflow/workflow_test.go index ca57d0b..cfd26a6 100644 --- a/internal/workflow/workflow_test.go +++ b/internal/workflow/workflow_test.go @@ -36,6 +36,51 @@ func TestParseRemainingKelosTriggers(t *testing.T) { } } +func TestJobEnvironmentScalarAndMappingForms(t *testing.T) { + for _, test := range []struct { + name string + value string + wantName string + wantURL string + }{ + {name: "scalar", value: "staging", wantName: "staging"}, + {name: "mapping", value: "\n name: ${{ inputs.environment }}-${{ matrix.arch }}\n url: https://deploy.example/${{ matrix.arch }}", wantName: "production-arm64", wantURL: "https://deploy.example/arm64"}, + } { + t.Run(test.name, func(t *testing.T) { + definition, err := Parse([]byte("name: Deploy\non: push\njobs:\n deploy:\n runs-on: ubuntu-latest\n environment: " + test.value + "\n strategy:\n matrix:\n arch: [arm64]\n steps:\n - run: deploy\n")) + if err != nil { + t.Fatal(err) + } + job, err := EvaluateJob("deploy", definition.Jobs["deploy"], workflowexpression.Context{ + Availability: workflowexpression.NewAvailability("github", "inputs", "matrix"), + Values: map[string]any{ + "github": map[string]any{}, "inputs": map[string]any{"environment": "production"}, "matrix": map[string]any{"arch": "arm64"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if job.Environment == nil || job.Environment.Name != test.wantName || job.Environment.URL != test.wantURL { + t.Fatalf("environment = %#v, want name %q url %q", job.Environment, test.wantName, test.wantURL) + } + }) + } +} + +func TestJobEnvironmentRejectsInvalidForms(t *testing.T) { + for _, environment := range []string{ + "{}", + "{name: production, unknown: value}", + "{name: production, name: staging}", + "${{ secrets.ENVIRONMENT }}", + } { + workflowText := "name: Deploy\non: push\njobs:\n deploy:\n runs-on: ubuntu-latest\n environment: " + environment + "\n steps:\n - run: deploy\n" + if _, err := Parse([]byte(workflowText)); err == nil { + t.Fatalf("Parse() accepted environment %q", environment) + } + } +} + func TestMatchWorkflowRunFilters(t *testing.T) { definition, err := Parse([]byte("name: Deploy\non:\n workflow_run:\n workflows: [Release]\n types: [completed]\n branches: [main]\n" + minimalJob)) if err != nil { diff --git a/internal/workflowstatus/status.go b/internal/workflowstatus/status.go index 01efd93..f1938c4 100644 --- a/internal/workflowstatus/status.go +++ b/internal/workflowstatus/status.go @@ -8,6 +8,7 @@ import ( const ( queued = "Queued" + waiting = "Waiting for approval" running = "Running" succeeded = "Succeeded" failed = "Failed" @@ -46,6 +47,9 @@ func Job(job *actionsv1alpha1.WorkflowJob) string { if job.Status.RunnerRef != nil { return running } + if job.Spec.Environment != nil && !meta.IsStatusConditionTrue(job.Status.Conditions, actionsv1alpha1.WorkflowJobConditionEnvironmentApproved) { + return waiting + } return queued } diff --git a/internal/workflowstatus/status_test.go b/internal/workflowstatus/status_test.go index 8983c62..08b8472 100644 --- a/internal/workflowstatus/status_test.go +++ b/internal/workflowstatus/status_test.go @@ -5,6 +5,7 @@ import ( actionsv1alpha1 "github.com/kelos-dev/open-actions/api/v1alpha1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -36,6 +37,7 @@ func TestJob(t *testing.T) { want string }{ {name: "waiting", job: &actionsv1alpha1.WorkflowJob{}, want: "Queued"}, + {name: "waiting for approval", job: workflowJobWaitingForApproval(), want: "Waiting for approval"}, {name: "running", job: &actionsv1alpha1.WorkflowJob{Status: actionsv1alpha1.WorkflowJobStatus{RunnerRef: &corev1.LocalObjectReference{Name: "runner"}}}, want: "Running"}, {name: "succeeded", job: workflowJobWithCondition(metav1.ConditionTrue), want: "Succeeded"}, {name: "failed", job: workflowJobWithCondition(metav1.ConditionFalse), want: "Failed"}, @@ -48,6 +50,12 @@ func TestJob(t *testing.T) { } } +func workflowJobWaitingForApproval() *actionsv1alpha1.WorkflowJob { + job := &actionsv1alpha1.WorkflowJob{Spec: actionsv1alpha1.WorkflowJobSpec{Environment: &actionsv1alpha1.WorkflowJobEnvironment{Name: "production"}}} + meta.SetStatusCondition(&job.Status.Conditions, metav1.Condition{Type: actionsv1alpha1.WorkflowJobConditionEnvironmentApproved, Status: metav1.ConditionFalse, Reason: "ApprovalRequired"}) + return job +} + func TestJobTerminal(t *testing.T) { for _, test := range []struct { status metav1.ConditionStatus