Skip to content

Commit eee25a1

Browse files
feat(front): use a job to fetch yamls in workflow editor (#206)
The workflow editor currently fetches yaml files in a synchronous call before the page is rendered, which is very unstable for large repositories and can lead to 500 errors. This PR changes this behavior so the workflow editor starts with a zero state screen with the spinner, and the job is started in the background to fetch yaml files asynchronously. Once the files are fetched successfully, the workflow editor is fully rendered with visual and text editor components. This behavior will remain behind a feature flag until we verify that it works as expected at scale. Co-authored-by: Lucas Pinheiro <[email protected]>
1 parent e70cebe commit eee25a1

File tree

13 files changed

+662
-52
lines changed

13 files changed

+662
-52
lines changed

front/assets/js/workflow_editor/editor.js

Lines changed: 142 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export class WorkflowEditor {
3333
workflowData: InjectedDataByBackend.WorkflowData,
3434
agentTypes: InjectedDataByBackend.AgentTypes,
3535
deploymentTargets: InjectedDataByBackend.DeploymentTargetsList,
36+
checkFetchingJobPath: InjectedDataByBackend.CheckFetchingJobPath,
37+
fetchingJobId: InjectedDataByBackend.FetchingJobId,
3638
commitInfo: {
3739
paths: {
3840
dismiss: InjectedDataByBackend.CommitForm.DismissPath,
@@ -67,20 +69,129 @@ export class WorkflowEditor {
6769
}
6870

6971
constructor(config) {
70-
this.config = config
72+
this.config = config;
7173

72-
Promotion.setProjectName(this.config.projectName)
73-
Promotion.setValidDeploymentTargets(this.config.deploymentTargets)
74-
Secrets.setValidSecretNames(this.config.orgSecretNames, this.config.projectSecretNames)
75-
Agent.setValidAgentTypes(this.config.agentTypes)
74+
Promotion.setProjectName(this.config.projectName);
75+
Promotion.setValidDeploymentTargets(this.config.deploymentTargets);
76+
Secrets.setValidSecretNames(this.config.orgSecretNames, this.config.projectSecretNames);
77+
Agent.setValidAgentTypes(this.config.agentTypes);
7678

77-
this.setUpModelComponentEventLoop()
79+
if(Features.isEnabled("useFetchingJob")) {
80+
this.registerLeavePageHandler();
81+
82+
this.disableButtonsInHeader();
7883

79-
this.registerLeavePageHandler()
84+
this.toggleWorkflowEditor();
8085

81-
this.preselectFirstBlock()
86+
this.toggleZeroState();
87+
88+
this.waitForFetchingJob();
89+
} else {
90+
this.setUpModelComponentEventLoop();
91+
92+
this.registerLeavePageHandler();
93+
94+
this.preselectFirstBlock();
95+
}
96+
}
97+
98+
disableButtonsInHeader() {
99+
$("[data-action=editorDismiss]").prop("disabled", true);
100+
$("[data-action=toggleCommitDialog]").prop("disabled", true);
101+
}
102+
103+
enableButtonsInHeader() {
104+
$("[data-action=editorDismiss]").prop("disabled", false);
105+
$("[data-action=toggleCommitDialog]").prop("disabled", false);
106+
}
107+
108+
toggleWorkflowEditor() {
109+
$("#workflow-editor-tabs").toggle();
110+
$("#workflow-editor-container").toggle();
82111
}
83112

113+
toggleZeroState() {
114+
$("#zero-state-loading-message").toggle();
115+
}
116+
117+
waitForFetchingJob() {
118+
let url = this.config.checkFetchingJobPath + `?job_id=${this.config.fetchingJobId}`;
119+
120+
console.log(`Checking fetching job ${url}`);
121+
122+
fetch(url)
123+
.then((res) => {
124+
const contentType = res.headers.get("content-type");
125+
126+
if(contentType && contentType.includes("application/json")) {
127+
return res.json();
128+
} else {
129+
throw new Error(res.statusText);
130+
}
131+
})
132+
.then((res) => {
133+
if(res.error) {
134+
throw new Error(res.error);
135+
} else {
136+
return res;
137+
}
138+
})
139+
.then((data) => {
140+
if(!data.finished) {
141+
setTimeout(this.waitForFetchingJob.bind(this), 1000);
142+
} else {
143+
this.fetchFilesAndShowEditor(data.signed_urls);
144+
}
145+
})
146+
.catch(
147+
(reason) => {
148+
console.log(reason);
149+
this.enableExitAndShowErrorMessage();
150+
}
151+
)
152+
}
153+
154+
enableExitAndShowErrorMessage() {
155+
this.disableOnLeaveConfirm();
156+
$("[data-action=editorDismiss]").prop("disabled", false);
157+
158+
$("#zero-state-title").text('Searching git repository for .yml files has failed.');
159+
160+
const message1 = "There was an issue with searching your git repository for .yml files.";
161+
$("#zero-state-paragraph-one").text(message1);
162+
$("#zero-state-paragraph-one").addClass("red");
163+
164+
const message2 = 'Please try reloading the page and contact support if the issue persists.';
165+
$("#zero-state-paragraph-two").text(message2);
166+
$("#zero-state-paragraph-two").addClass("red");
167+
}
168+
169+
fetchFilesAndShowEditor(urls) {
170+
fetchFilesSequentially(urls)
171+
.then(({ updatedFiles, errors }) => {
172+
if (errors.length > 0) {
173+
console.warn("Some files failed to fetch:", errors);
174+
175+
this.enableExitAndShowErrorMessage();
176+
} else {
177+
console.log("All .yml files were fetched successfully.");
178+
179+
this.config.workflowData.yamls = updatedFiles;
180+
181+
this.toggleZeroState();
182+
183+
this.toggleWorkflowEditor();
184+
185+
this.enableButtonsInHeader();
186+
187+
this.setUpModelComponentEventLoop();
188+
189+
this.preselectFirstBlock();
190+
}
191+
});
192+
}
193+
194+
84195
//
85196
// Sets up a model-component event loop.
86197
//
@@ -215,3 +326,26 @@ export class WorkflowEditor {
215326
SelectionRegister.setCurrentSelectionUid(uid)
216327
}
217328
}
329+
330+
async function fetchFilesSequentially(fileMap) {
331+
const errors = [];
332+
333+
for (const [filePath, signedUrl] of Object.entries(fileMap)) {
334+
try {
335+
const response = await fetch(signedUrl);
336+
if (!response.ok) {
337+
throw new Error(`Failed to fetch ${filePath}: ${response.statusText}`);
338+
}
339+
const content = await response.text();
340+
fileMap[filePath] = content;
341+
} catch (error) {
342+
console.error(`Error fetching ${filePath}:`, error);
343+
errors.push({ filePath, error });
344+
}
345+
}
346+
347+
return {
348+
updatedFiles: fileMap,
349+
errors,
350+
};
351+
}

front/config/.credo.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
# or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just
3636
# set the `excluded_macros` parameter to `[:schema, :setup, :test]`.
3737
#
38-
{Credo.Check.Design.DuplicatedCode, excluded_macros: [:test], mass_threshold: 45},
38+
{Credo.Check.Design.DuplicatedCode, excluded_macros: [:test], mass_threshold: 55},
3939
# You can also customize the exit_status of each check.
4040
# If you don't want TODO comments to cause `mix credo` to fail, just
4141
# set this value to 0 (zero).

front/lib/front/models/artifacthub.ex

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,17 @@ defmodule Front.Models.Artifacthub do
4646
end)
4747
end
4848

49-
def list(project_id, source_kind, source_id, path \\ "") do
49+
def list(project_id, source_kind, source_id, path \\ "", unwrap_directories \\ false) do
5050
Watchman.benchmark("artifacthub.list_request.duration", fn ->
5151
with req_path <- Resource.request_path(source_kind, source_id, path),
5252
{:ok, store_id} <- get_artifact_store_id(project_id),
5353
{:ok, channel} <- GRPC.Stub.connect(api_endpoint()),
54-
request <- ListRequest.new(artifact_id: store_id, path: req_path),
54+
request <-
55+
ListRequest.new(
56+
artifact_id: store_id,
57+
path: req_path,
58+
unwrap_directories: unwrap_directories
59+
),
5560
{:ok, response} <- Stub.list_path(channel, request, timeout: 30_000),
5661
{:ok, artifacts} <- parse_list_response(response, source_kind, source_id, req_path) do
5762
{:ok, artifacts}
@@ -119,6 +124,24 @@ defmodule Front.Models.Artifacthub do
119124
end)
120125
end
121126

127+
def list_and_sign_urls(project_id, source_kind, source_id, relative_path) do
128+
case list(project_id, source_kind, source_id, relative_path, true) do
129+
{:ok, artifacts} ->
130+
Enum.reduce_while(artifacts, {:ok, %{}}, fn artifact, {_, urls} ->
131+
case signed_url(project_id, source_kind, source_id, artifact.path) do
132+
{:ok, url} ->
133+
{:cont, {:ok, Map.put(urls, artifact.path, url)}}
134+
135+
e ->
136+
{:halt, {:error, "error generating signed URL for #{artifact.path}: #{inspect(e)}"}}
137+
end
138+
end)
139+
140+
e ->
141+
e
142+
end
143+
end
144+
122145
def signed_url(project_id, source_kind, source_id, relative_path, method \\ "GET") do
123146
case get_artifact_store_id(project_id) do
124147
{:ok, store_id} ->
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
defmodule Front.Models.FetchingJob do
2+
@moduledoc """
3+
Module encapsulates all functions needed to define the job used for fetching
4+
the yaml files needed in workflow editor from the git repository.
5+
"""
6+
7+
alias Front.Models.OrganizationSettings
8+
alias InternalApi.ServerFarm.Job.JobSpec
9+
alias Front.Models.Job
10+
11+
require Logger
12+
13+
def start_fetching_job(params) do
14+
with {:ok, agent} <- get_agent(params.project),
15+
{:ok, job_spec} <- create_job_spec(agent, params),
16+
{:ok, job} <- Job.create(job_spec, params) do
17+
{:ok, job.id}
18+
else
19+
error ->
20+
Logger.error(
21+
Enum.join(
22+
[
23+
"Could not create fetching job",
24+
"project: #{params.project.id}",
25+
"branch: #{params.target_branch}",
26+
"user: #{params.user_id}",
27+
"error: #{inspect(error)}"
28+
],
29+
", "
30+
)
31+
)
32+
33+
{:error, :fetching_job_creation_failed}
34+
end
35+
end
36+
37+
defp get_agent(project) do
38+
case OrganizationSettings.fetch(project.organization_id) do
39+
{:ok, settings} -> decide_on_agent(settings)
40+
error -> {:error, :fetch_agent, error}
41+
end
42+
end
43+
44+
defp decide_on_agent(%{"custom_machine_type" => type, "custom_os_image" => os_image})
45+
when is_binary(type) and type != "" do
46+
{:ok, %{type: type, os_image: os_image}}
47+
end
48+
49+
defp decide_on_agent(%{"plan_machine_type" => type, "plan_os_image" => os_image})
50+
when is_binary(type) and type != "" do
51+
{:ok, %{type: type, os_image: os_image}}
52+
end
53+
54+
defp decide_on_agent(_seetings), do: {:error, :settings_without_agent_def}
55+
56+
defp create_job_spec(agent, params) do
57+
{:ok,
58+
%JobSpec{
59+
job_name: "Workflow editor fetching files * #{params.project.name} * #{params.hook.name}",
60+
agent: %JobSpec.Agent{
61+
machine: %JobSpec.Agent.Machine{
62+
os_image: agent.os_image,
63+
type: agent.type
64+
},
65+
containers: [],
66+
image_pull_secrets: []
67+
},
68+
secrets: [],
69+
env_vars: [],
70+
files: [],
71+
commands: generate_commands(params),
72+
epilogue_always_commands: [],
73+
epilogue_on_pass_commands: [],
74+
epilogue_on_fail_commands: [],
75+
priority: 95,
76+
execution_time_limit: 10
77+
}}
78+
end
79+
80+
defp generate_commands(params) do
81+
[
82+
"export SEMAPHORE_GIT_DEPTH=5",
83+
configure_env_vars_for_checkout(params),
84+
"checkout",
85+
"artifact push job .semaphore -d .workflow_editor/.semaphore"
86+
]
87+
|> List.flatten()
88+
end
89+
90+
defp configure_env_vars_for_checkout(params) do
91+
case params.hook.type do
92+
"branch" -> set_branch_env_vars(params.hook)
93+
"tag" -> set_tag_env_vars(params.hook)
94+
"pr" -> set_pr_env_vars(params.hook)
95+
:skip -> []
96+
end
97+
end
98+
99+
defp set_branch_env_vars(hook) do
100+
[
101+
"export SEMAPHORE_GIT_REF_TYPE=branch",
102+
"export SEMAPHORE_GIT_BRANCH=#{hook.branch_name}",
103+
"export SEMAPHORE_GIT_SHA=#{hook.head_commit_sha}"
104+
]
105+
end
106+
107+
defp set_tag_env_vars(hook) do
108+
[
109+
"export SEMAPHORE_GIT_REF_TYPE=tag",
110+
"export SEMAPHORE_GIT_TAG_NAME=#{hook.tag_name}",
111+
"export SEMAPHORE_GIT_SHA=#{hook.head_commit_sha}"
112+
]
113+
end
114+
115+
defp set_pr_env_vars(hook) do
116+
[
117+
"export SEMAPHORE_GIT_REF_TYPE=pull-request",
118+
"export SEMAPHORE_GIT_REF=refs/pull/#{hook.pr_number}/merge",
119+
"export SEMAPHORE_GIT_SHA=#{hook.head_commit_sha}"
120+
]
121+
end
122+
end

0 commit comments

Comments
 (0)