Skip to content

Commit b3a9f8b

Browse files
committed
refactor(workflow): establish safe CI check runner and pipeline validation
1 parent bf8df02 commit b3a9f8b

11 files changed

Lines changed: 455 additions & 46 deletions

File tree

.github/workflows/sailr-ci.yml

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
11
name: Sailr Workflow - ci
22

33
on:
4-
push:
5-
branches: [ "main" ]
64
pull_request:
7-
branches: [ "main" ]
5+
push:
6+
branches: [main]
87

98
jobs:
109
sailr-workflow:
1110
runs-on: ubuntu-latest
1211
steps:
13-
- name: Checkout code
14-
uses: actions/checkout@v4
15-
12+
- uses: actions/checkout@v4
13+
1614
- name: Install Sailr
17-
run: curl -sSL https://sailr.dev/install.sh | bash
18-
15+
run: cargo install --path .
16+
1917
- name: Run Workflow
20-
run: sailr workflow run ci
18+
run: sailr workflow run ci --non-interactive

k8s/environments/local/config.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,12 @@ log_level = "info"
44
domain = "local.dev"
55
default_replicas = 1
66
registry = "local.registry"
7+
8+
[[service]]
9+
name = "hello-sailr"
10+
version = "latest"
11+
path = "hello-sailr"
12+
13+
[service.build]
14+
path = "."
15+
dockerfile = "Dockerfile"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
apiVersion: v1
2+
kind: ConfigMap
3+
metadata:
4+
name: hello-sailr-config
5+
labels:
6+
app: hello-sailr
7+
type: web-app
8+
data:
9+
NODE_ENV: "production"
10+
LOG_LEVEL: "info"
11+
PORT: "80"
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
apiVersion: apps/v1
2+
kind: Deployment
3+
metadata:
4+
name: hello-sailr
5+
labels:
6+
app: hello-sailr
7+
type: web-app
8+
spec:
9+
replicas: 3
10+
selector:
11+
matchLabels:
12+
app: hello-sailr
13+
template:
14+
metadata:
15+
labels:
16+
app: hello-sailr
17+
type: web-app
18+
spec:
19+
containers:
20+
- name: hello-sailr
21+
image: nginx:latest
22+
ports:
23+
- containerPort: 80
24+
env:
25+
- name: PORT
26+
value: "80"
27+
- name: NODE_ENV
28+
valueFrom:
29+
configMapKeyRef:
30+
name: hello-sailr-config
31+
key: NODE_ENV
32+
resources:
33+
requests:
34+
memory: "128Mi"
35+
cpu: "100m"
36+
limits:
37+
memory: "512Mi"
38+
cpu: "500m"
39+
livenessProbe:
40+
httpGet:
41+
path: /health
42+
port: 80
43+
initialDelaySeconds: 30
44+
periodSeconds: 10
45+
readinessProbe:
46+
httpGet:
47+
path: /ready
48+
port: 80
49+
initialDelaySeconds: 5
50+
periodSeconds: 5
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
apiVersion: networking.k8s.io/v1
2+
kind: Ingress
3+
metadata:
4+
name: hello-sailr
5+
labels:
6+
app: hello-sailr
7+
type: web-app
8+
annotations:
9+
nginx.ingress.kubernetes.io/rewrite-target: /
10+
spec:
11+
rules:
12+
- host: local.dev
13+
http:
14+
paths:
15+
- path: /
16+
pathType: Prefix
17+
backend:
18+
service:
19+
name: hello-sailr
20+
port:
21+
number: 80
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
apiVersion: v1
2+
kind: Service
3+
metadata:
4+
name: hello-sailr
5+
labels:
6+
app: hello-sailr
7+
type: web-app
8+
spec:
9+
selector:
10+
app: hello-sailr
11+
ports:
12+
- protocol: TCP
13+
port: 80
14+
targetPort: 80
15+
type: ClusterIP

src/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ pub struct WorkflowRunArgs {
8686

8787
#[arg(long)]
8888
pub ignore: Option<String>,
89-
89+
9090
#[arg(long)]
9191
pub non_interactive: bool,
9292

src/main.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -825,7 +825,10 @@ async fn handle_workflow(cmd: WorkflowCommands) -> Result<(), CliError> {
825825
let profile = config.get_profile(&args.profile).ok_or_else(|| {
826826
CliError::Other(format!("Workflow profile '{}' not found", args.profile))
827827
})?;
828-
println!("{}", sailr::workflow::config::WorkflowConfig::format_profile_detail(profile));
828+
println!(
829+
"{}",
830+
sailr::workflow::config::WorkflowConfig::format_profile_detail(profile)
831+
);
829832
}
830833
WorkflowCommands::GenerateCi(args) => {
831834
let profile = config.get_profile(&args.profile).ok_or_else(|| {

src/workflow/planner.rs

Lines changed: 120 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ pub struct WorkflowPlanner {
1414
}
1515

1616
impl WorkflowPlanner {
17-
pub fn new(profile: NormalizedWorkflowProfile, env: Arc<Environment>, options: BuildOptions) -> Self {
17+
pub fn new(
18+
profile: NormalizedWorkflowProfile,
19+
env: Arc<Environment>,
20+
options: BuildOptions,
21+
) -> Self {
1822
Self {
1923
profile,
2024
env,
@@ -27,32 +31,40 @@ impl WorkflowPlanner {
2731
let mut build_plan = None;
2832

2933
// 0. Validate Phase
30-
if self.profile.deploy.is_active() {
31-
if self.profile.deploy_context.is_none() {
32-
return Err("Validation Error: deploy_context is required when deploy is active".to_string());
33-
}
34-
return Err("workflow deploy execution is not enabled in this PR; use sailr deploy or sailr go".to_string());
35-
}
3634

37-
let validate_task = Task::new("workflow:validate-config")
38-
.exec_fn(move |_ctx| async move {
39-
crate::LOGGER.info("Validating Sailr environment config...");
40-
Ok(())
41-
});
35+
let validate_task = Task::new("workflow:validate-config").exec_fn(move |_ctx| async move {
36+
crate::LOGGER.info("Validating Sailr environment config...");
37+
Ok(())
38+
});
4239
pipeline.add(validate_task);
4340
let mut last_tasks: Vec<String> = vec!["workflow:validate-config".to_string()];
4441

4542
// 1. Build Phase
4643
if self.profile.build.is_active() {
4744
let plan = create_sailr_build_plan(&self.env, &self.options)?;
48-
45+
4946
// Only add runkernel tasks if we actually want to run the build.
50-
// If build == Plan, create_sailr_build_plan already printed the plan (via builder.rs integration),
51-
// but we don't want to execute it. Wait, create_sailr_build_plan does not print the plan.
52-
// In RunkernelBuildBackend::build, it prints it.
47+
// If build == Plan, create_sailr_build_plan already printed the plan (via builder.rs integration),
48+
// but we don't want to execute it. Wait, create_sailr_build_plan does not print the plan.
49+
// In RunkernelBuildBackend::build, it prints it.
5350
// But we can just avoid adding tasks to the pipeline if it's just plan.
5451
if self.profile.build == crate::workflow::profile::WorkflowStepMode::Plan {
55-
crate::builder::print_sailr_plan(&plan, &self.options);
52+
let plan_for_task = plan.clone();
53+
let options_for_task = self.options.clone();
54+
55+
let task = Task::new("workflow:build-plan")
56+
.depends_on(&["workflow:validate-config"])
57+
.exec_fn(move |_ctx| {
58+
let p = plan_for_task.clone();
59+
let o = options_for_task.clone();
60+
async move {
61+
crate::builder::print_sailr_plan(&p, &o);
62+
Ok(())
63+
}
64+
});
65+
66+
pipeline.add(task);
67+
last_tasks = vec!["workflow:build-plan".to_string()];
5668
build_plan = Some(plan);
5769
} else {
5870
add_runkernel_tasks(&mut pipeline, &plan)?;
@@ -66,7 +78,7 @@ impl WorkflowPlanner {
6678
build_deps.push(s.service.name.clone());
6779
}
6880
}
69-
81+
7082
last_tasks.extend(build_deps);
7183
build_plan = Some(plan);
7284
}
@@ -114,3 +126,93 @@ impl WorkflowPlanner {
114126
Ok((pipeline, build_plan))
115127
}
116128
}
129+
130+
#[cfg(test)]
131+
mod tests {
132+
use super::*;
133+
use crate::workflow::profile::{
134+
ApprovalMode, ReportMode, WorkflowEngine, WorkflowMode, WorkflowStepMode,
135+
};
136+
137+
fn dummy_profile(
138+
deploy_mode: WorkflowStepMode,
139+
build_mode: WorkflowStepMode,
140+
) -> NormalizedWorkflowProfile {
141+
NormalizedWorkflowProfile {
142+
name: "test".to_string(),
143+
environment: "local".to_string(),
144+
mode: WorkflowMode::Check,
145+
engine: WorkflowEngine::Runkernel,
146+
interactive: false,
147+
build: build_mode,
148+
generate: WorkflowStepMode::Run,
149+
deploy: deploy_mode,
150+
test: WorkflowStepMode::Disabled,
151+
verify: WorkflowStepMode::Disabled,
152+
deploy_context: Some("local".to_string()),
153+
namespace: Some("default".to_string()),
154+
approval: ApprovalMode::None,
155+
apply: false,
156+
report: ReportMode::Text,
157+
}
158+
}
159+
160+
fn dummy_options(plan: bool) -> BuildOptions {
161+
BuildOptions {
162+
cache_dir: ".sailr".to_string(),
163+
force: false,
164+
only: vec![],
165+
ignore: vec![],
166+
plan,
167+
dry_run: false,
168+
explain: false,
169+
dump_scope: false,
170+
policy: Default::default(),
171+
}
172+
}
173+
174+
#[test]
175+
fn check_profile_never_creates_deploy() {
176+
let env = Environment::load_from_file("local").unwrap();
177+
let profile = dummy_profile(WorkflowStepMode::Disabled, WorkflowStepMode::Plan);
178+
let planner = WorkflowPlanner::new(profile, Arc::new(env), dummy_options(true));
179+
let (pipeline, _) = planner.build_pipeline().unwrap();
180+
assert!(!pipeline.tasks().any(|t| t.name.starts_with("deploy:")));
181+
}
182+
183+
#[test]
184+
fn check_profile_creates_validate_config() {
185+
let env = Environment::load_from_file("local").unwrap();
186+
let profile = dummy_profile(WorkflowStepMode::Disabled, WorkflowStepMode::Plan);
187+
let planner = WorkflowPlanner::new(profile, Arc::new(env), dummy_options(true));
188+
let (pipeline, _) = planner.build_pipeline().unwrap();
189+
assert!(pipeline.tasks().any(|t| t.name == "workflow:validate-config"));
190+
}
191+
192+
#[test]
193+
fn check_profile_creates_build_plan_when_build_plan() {
194+
let env = Environment::load_from_file("local").unwrap();
195+
let profile = dummy_profile(WorkflowStepMode::Disabled, WorkflowStepMode::Plan);
196+
let planner = WorkflowPlanner::new(profile, Arc::new(env), dummy_options(true));
197+
let (pipeline, _) = planner.build_pipeline().unwrap();
198+
assert!(pipeline.tasks().any(|t| t.name == "workflow:build-plan"));
199+
}
200+
201+
#[test]
202+
fn build_disabled_creates_no_build_plan() {
203+
let env = Environment::load_from_file("local").unwrap();
204+
let profile = dummy_profile(WorkflowStepMode::Disabled, WorkflowStepMode::Disabled);
205+
let planner = WorkflowPlanner::new(profile, Arc::new(env), dummy_options(false));
206+
let (pipeline, _) = planner.build_pipeline().unwrap();
207+
assert!(!pipeline.tasks().any(|t| t.name == "workflow:build-plan"));
208+
}
209+
210+
#[test]
211+
fn generate_task_is_created_when_generate_run() {
212+
let env = Environment::load_from_file("local").unwrap();
213+
let profile = dummy_profile(WorkflowStepMode::Disabled, WorkflowStepMode::Plan);
214+
let planner = WorkflowPlanner::new(profile, Arc::new(env), dummy_options(true));
215+
let (pipeline, _) = planner.build_pipeline().unwrap();
216+
assert!(pipeline.tasks().any(|t| t.name == "workflow:generate"));
217+
}
218+
}

0 commit comments

Comments
 (0)