@silexlabs/silex
Version:
Free and easy website builder for everyone.
551 lines (501 loc) • 21.3 kB
text/typescript
/*
* Silex website builder, free/libre no-code tool for makers.
* Copyright (c) 2023 lexoyo and Silex Labs foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Gitlab hosting connector
* @fileoverview Gitlab hosting connector for Silex, connect to the user's Gitlab account to host websites with .gitlab-ci.yml file template for plain html by default
* @see https://docs.gitlab.com/ee/api/oauth2.html
* @see https://docs.gitlab.com/ee/user/project/pages/getting_started/pages_ci_cd_template.html
*/
import dedent from 'dedent'
import GitlabConnector, { GitlabOptions, GitlabSession} from './GitlabConnector'
import { HostingConnector, ConnectorFile } from '../../server/connectors/connectors'
import { ConnectorType, WebsiteId, JobData, JobStatus, PublicationJobData } from '../../types'
import { JobManager } from '../../server/jobs'
import { ServerConfig } from '../../server/config'
import { setTimeout } from 'timers/promises'
const waitTimeOut = 5000 /* for wait loop waiting for job to appear */
const jobPollInterval = 10000 /* poll job status every 10 seconds */
const jobCompletionTimeOut = 45 * 60 * 1000 /* 45 minutes max for job completion */
const SILEX_OVERWRITE_NOTICE = '# silexOverwrite: true'
// GitLab job statuses that indicate the job is still running
const GITLAB_RUNNING_STATUSES = ['created', 'waiting_for_resource', 'preparing', 'pending', 'running']
// GitLab job statuses that indicate success
const GITLAB_SUCCESS_STATUSES = ['success']
// GitLab job statuses that indicate failure
const GITLAB_FAILED_STATUSES = ['failed', 'canceled', 'skipped']
/**
* Filter GitLab trace to remove technical noise
* Returns an array of meaningful log lines
*/
function filterTraceLines(trace: string): string[] {
return trace.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0)
// Remove GitLab internal markers
.filter(l => !l.match(/^section_/))
// eslint-disable-next-line no-control-regex
.filter(l => !l.match(/^\x1b/)) // Remove ANSI escape sequences at start
// Remove timestamps at the beginning of lines
.map(l => l.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*\d*O?\s*/, ''))
// Remove Docker/Git setup noise
.filter(l => !l.includes('Using Docker executor'))
.filter(l => !l.includes('Pulling docker image'))
.filter(l => !l.includes('Fetching changes'))
.filter(l => !l.includes('Checking out'))
.filter(l => !l.includes('Skipping Git submodules'))
.filter(l => !l.includes('Getting source from Git'))
.filter(l => !l.includes('Executing "step_script"'))
.filter(l => !l.includes('Restoring cache'))
.filter(l => !l.includes('Saving cache'))
.filter(l => !l.match(/^\s*\$\s/)) // Remove shell command echoes
.filter(l => !l.match(/^Running with gitlab-runner/))
.filter(l => !l.match(/^Preparing the .* executor/))
.filter(l => !l.match(/^Using .* as base image/))
// Keep non-empty lines after filtering
.filter(l => l.trim().length > 0)
}
/**
* Parse GitLab/11ty job logs to extract meaningful status
* Returns a short user-friendly status message
*/
function parseJobTrace(trace: string): string {
const lines = filterTraceLines(trace)
// Look for 11ty build progress
if (trace.includes('[11ty]')) {
// Look for 11ty completion first
if (trace.includes('[11ty] Wrote') || trace.includes('[11ty] Copied')) {
const wroteMatch = trace.match(/\[11ty\] Wrote (\d+)/i)
const copiedMatch = trace.match(/\[11ty\] Copied (\d+)/i)
const parts = []
if (wroteMatch) parts.push(`${wroteMatch[1]} pages generated`)
if (copiedMatch) parts.push(`${copiedMatch[1]} files copied`)
if (parts.length) return parts.join(', ')
}
// Look for 11ty processing
const eleventyMatch = trace.match(/\[11ty\].*?(\d+)\s+files?/i)
if (eleventyMatch) {
return `Generating ${eleventyMatch[1]} pages...`
}
return 'Building pages...'
}
// Look for npm/npx installing
if (trace.includes('npm') && trace.includes('install')) {
return 'Installing dependencies...'
}
// Look for npx running eleventy
if (trace.includes('npx') && trace.includes('eleventy')) {
return 'Running page generator...'
}
// Look for artifact upload
if (trace.includes('Uploading artifacts')) {
return 'Preparing files for deployment...'
}
// Look for pages deployment
if (trace.includes('CI_PAGES_URL') || trace.includes('will be deployed')) {
return 'Finalizing deployment...'
}
// Look for job completion
if (trace.includes('Job succeeded')) {
return 'Build complete'
}
// Look for errors
const errorMatch = trace.match(/error[:\s]+(.{0,100})/i)
if (errorMatch) {
const errorMsg = errorMatch[1].trim().substring(0, 60)
return `Error: ${errorMsg}${errorMsg.length >= 60 ? '...' : ''}`
}
// Return empty string to avoid showing technical noise
return ''
}
export default class GitlabHostingConnector extends GitlabConnector implements HostingConnector {
displayName = 'Gitlab hosting'
connectorType = ConnectorType.HOSTING
constructor( config: ServerConfig, opts: Partial<GitlabOptions>) {
super (config, opts)
// public directory for standard gitlab pages
this.options.assetsFolder= 'public'
}
async publish(session: GitlabSession, websiteId: WebsiteId, files: ConnectorFile[], {startJob, jobSuccess, jobError}: JobManager): Promise<JobData> {
const job = startJob(`Publishing to ${this.displayName}`) as PublicationJobData
job.logs = [[`Publishing to ${this.displayName}`]]
job.errors = [[]]
/* Configuration file .gitlab-ci.yml contains template for plain html Gitlab pages*/
const pathYml = '.gitlab-ci.yml'
const contentYml = dedent`
${SILEX_OVERWRITE_NOTICE}
# You will want to remove these lines if you customize your build process
# Silex will overwrite this file unless you remove these lines
# This is the default build process for plain eleventy sites
image: node:20
pages:
stage: deploy
environment: production
script:${files.find(file => file.path.includes('.11tydata.')) ? `
- npx @11ty/eleventy@v3.0.0-alpha.20 --input=public --output=_site
- mkdir -p public/css public/assets && cp -R public/css public/assets _site/
- rm -rf public && mv _site public`
: ''}
- echo "The site will be deployed to $CI_PAGES_URL"
artifacts:
paths:
- public
rules:
- if: '$CI_COMMIT_TAG'
- if: '$CI_PIPELINE_SOURCE == "trigger"'
`
try {
job.message = `Checking if ${pathYml} needs to be created or updated...`
job.logs[0].push(job.message)
const originalCi = await this.readFile(session, websiteId, pathYml)
if(originalCi.toString().startsWith(SILEX_OVERWRITE_NOTICE)) {
job.message = `Updating ${pathYml}...`
job.logs[0].push(job.message)
await this.updateFile(session, websiteId, pathYml, contentYml)
}
} catch (e) {
// If the file .gitlab-ci.yml does not exist, create it, otherwise do nothing (do not overwriting existing one)
if (e.statusCode === 404 || e.httpStatusCode === 404 || e.message.endsWith('A file with this name doesn\'t exist')) {
job.message = `Creating ${pathYml}...`
job.logs[0].push(job.message)
await this.createFile(session, websiteId, pathYml, contentYml)
} else {
jobError(job.jobId, e.message)
}
}
// publishing all files for website
// Do not await for the result, return the job and continue the publication in the background
//this .startPublicationJob(session, websiteId, files, job, () => this.endPublicationJob(session, websiteId, job))
this.writeAssets(session, websiteId, files, async ({status, message}) => {
// Update the job status
if(status === JobStatus.SUCCESS) {
/* Squash and tag the commits */
const successTag = await this.createTag(session, websiteId, job, { startJob, jobSuccess, jobError })
if(!successTag) {
// jobError will have been called in createTag
return
}
try {
job.message = 'Getting the website URL...'
job.logs[0].push(job.message)
const gitlabUrl = await this.getUrl(session, websiteId)
job.logs[0].push(`Website URL: ${gitlabUrl}`)
job.message = 'Getting the admin URL...'
job.logs[0].push(job.message)
const adminUrl = await this.getAdminUrl(session, websiteId)
job.logs[0].push(`Admin URL: ${adminUrl}`)
job.message = 'Getting the page URL...'
job.logs[0].push(job.message)
const pageUrl = await this.getPageUrl(session, websiteId, adminUrl)
job.logs[0].push(`Page URL: ${pageUrl}`)
// Wait for the GitLab job to complete
job.message = 'Starting build...'
job.logs[0].push(job.message)
const result = await this.waitForGitlabJobCompletion(session, websiteId, job, adminUrl, successTag, gitlabUrl, pageUrl)
if (result.success) {
jobSuccess(job.jobId, result.message)
} else {
jobError(job.jobId, result.message)
}
} catch (error) {
console.error('Error during getting the website URLs:', error.message)
jobError(job.jobId, `Failed to get the website URLs: ${error.message}`)
}
} else if (status === JobStatus.ERROR) {
job.errors[0].push(message)
jobError(job.jobId, message)
} else {
// Update the job status while uploading files
job.status = status
job.message = message
job.logs[0].push(message)
}
}, true)
.catch(e => {
console.error('Error uploading files to gitlab:', e.message)
jobError(job.jobId, `Failed to upload files: ${e.message}`)
})
return job
}
// async startPublicationJob(session: GitlabSession, websiteId: WebsiteId, files: ConnectorFile[], job: PublicationJobData, endJob: () => void) {
// job.message = `Preparing ${files.length} files...`
// job.logs[0].push(job.message)
// job.status = JobStatus.IN_PROGRESS
// job.startTime = Date.now()
// // List all the files in assets folder
// const existingFiles = await this.ls({
// session,
// websiteId,
// recursive: false,
// path: this.options.assetsFolder,
// })
// // Create the actions for the batch
// const filesToUpload = [] as GitlabAction[]
// for (const file of files) {
// const filePath = this.getAssetPath(file.path, false)
// const content = (await contentToBuffer(file.content)).toString('base64')
// const existingSha = existingFiles.get(filePath)
// const newSha = computeGitBlobSha(content, true)
// if (existingSha) {
// if (existingSha !== newSha) {
// filesToUpload.push({
// action: 'update',
// file_path: filePath,
// content,
// encoding: 'base64',
// })
// } // else: skip unchanged file
// } else {
// filesToUpload.push({
// action: 'create',
// file_path: filePath,
// content,
// encoding: 'base64',
// })
// }
/* Get and return Url Gitlab Pages */
async getUrl(session: GitlabSession, websiteId: WebsiteId): Promise<string> {
const response = await this.callApi({
session,
path: `api/v4/projects/${websiteId}/pages`,
method: 'GET'
})
return response.url
}
async getAdminUrl(session: GitlabSession, websiteId: WebsiteId): Promise<string> {
const projectInfo = await this.callApi({
session,
path: `api/v4/projects/${websiteId}`,
method: 'GET'
})
return projectInfo.web_url
}
async getPageUrl(session: GitlabSession, websiteId: WebsiteId, projectUrl: string): Promise<string> {
return `${projectUrl}/pages`
}
/**
* Wait for the GitLab CI/CD job to complete
* Polls the job status and updates the Silex job with progress
*/
async waitForGitlabJobCompletion(
session: GitlabSession,
websiteId: WebsiteId,
job: PublicationJobData,
projectUrl: string,
tag: string,
gitlabUrl: string,
pageUrl: string
): Promise<{ success: boolean; message: string }> {
const t0 = Date.now()
let gitlabJobId: number | null = null
let gitlabJobLogsUrl: string | null = null
// First, wait for the job to appear
while (!gitlabJobId && (Date.now() - t0) < this.options.timeOut) {
try {
const jobs = await this.callApi({
session,
path: `api/v4/projects/${websiteId}/jobs`,
method: 'GET'
})
const matchingJob = jobs.find(j => j.ref === tag)
if (matchingJob) {
gitlabJobId = matchingJob.id
gitlabJobLogsUrl = `${projectUrl}/-/jobs/${matchingJob.id}`
job.logs[0].push('Build started')
job.message = `
<p><strong>Building...</strong></p>
<div class="buttons">
<a href="${gitlabJobLogsUrl}" target="_blank" class="silex-button silex-button--secondary">View build logs</a>
</div>
`
}
} catch (e) {
console.error('Error fetching GitLab jobs:', e.message)
}
if (!gitlabJobId) {
await setTimeout(waitTimeOut)
}
}
// If job never appeared, handle the error
if (!gitlabJobId) {
let errorMessage = 'Could not start the build. The server may be busy or unavailable.'
if (this.isUsingOfficialInstance()) {
const verifyURL = 'https://gitlab.com/-/identity_verification'
errorMessage +=
`<div class="notice">
If your GitLab account is recent, you may need to <a href="${verifyURL}" target="_blank">verify it here</a>
before you can publish websites.
</div>`
}
return { success: false, message: errorMessage }
}
// Now poll the job status until it completes
let lastTraceLength = 0
while ((Date.now() - t0) < jobCompletionTimeOut) {
try {
// Get job status
const gitlabJob = await this.callApi({
session,
path: `api/v4/projects/${websiteId}/jobs/${gitlabJobId}`,
method: 'GET'
})
const jobStatus = gitlabJob.status
// Try to get job trace (logs) for progress info
let traceInfo = ''
try {
const trace = await this.callApi({
session,
path: `api/v4/projects/${websiteId}/jobs/${gitlabJobId}/trace`,
method: 'GET'
})
if (typeof trace === 'string' && trace.length > lastTraceLength) {
// Parse the new portion of the trace
const newTrace = trace.substring(lastTraceLength)
traceInfo = parseJobTrace(newTrace)
lastTraceLength = trace.length
// Add filtered log lines to the job logs for the <details> section
const filteredLines = filterTraceLines(newTrace)
if (filteredLines.length > 0) {
// Limit to last 50 lines to avoid overwhelming the logs
const linesToAdd = filteredLines.slice(-50)
job.logs[0].push(...linesToAdd)
}
}
} catch (e) {
// Trace might not be available yet, that's OK
}
// Map GitLab status to user-friendly status
const statusMap = {
'created': 'Starting',
'waiting_for_resource': 'Waiting for server',
'preparing': 'Preparing',
'pending': 'Queued',
'running': 'Building',
'success': 'Complete',
'failed': 'Failed',
'canceled': 'Canceled',
'skipped': 'Skipped',
}
const statusDisplay = statusMap[jobStatus] || jobStatus
const progressInfo = traceInfo ? ` - ${traceInfo}` : ''
job.message = `
<p><strong>${statusDisplay}</strong>${progressInfo}</p>
<div class="buttons">
<a href="${gitlabJobLogsUrl}" target="_blank" class="silex-button silex-button--secondary">View build logs</a>
</div>
`
// Check if job completed
if (GITLAB_SUCCESS_STATUSES.includes(jobStatus)) {
// Fetch final trace to include in logs
try {
const finalTrace = await this.callApi({
session,
path: `api/v4/projects/${websiteId}/jobs/${gitlabJobId}/trace`,
method: 'GET'
})
if (typeof finalTrace === 'string') {
const allFilteredLines = filterTraceLines(finalTrace)
// Add last 30 lines as summary
job.logs[0].push('--- Build Summary ---')
job.logs[0].push(...allFilteredLines.slice(-30))
}
} catch (e) {
// Ignore trace fetch errors
}
const message = `
<p><strong>Your website is now live!</strong></p>
<div class="buttons">
<a href="${gitlabUrl}" target="_blank" class="silex-button silex-button--primary">View your website</a>
<a href="${pageUrl}" target="_blank" class="silex-button silex-button--secondary">GitLab Pages settings</a>
</div>
`
job.logs[0].push('Build completed successfully')
return { success: true, message }
}
if (GITLAB_FAILED_STATUSES.includes(jobStatus)) {
const failureReason = gitlabJob.failure_reason || 'Build error'
// Fetch final trace to include error details in logs
try {
const finalTrace = await this.callApi({
session,
path: `api/v4/projects/${websiteId}/jobs/${gitlabJobId}/trace`,
method: 'GET'
})
if (typeof finalTrace === 'string') {
const allFilteredLines = filterTraceLines(finalTrace)
// Add last 50 lines to help debug the error
job.logs[0].push('--- Error Details ---')
job.logs[0].push(...allFilteredLines.slice(-50))
}
} catch (e) {
// Ignore trace fetch errors
}
const message = `
<p><strong>Build failed:</strong> ${failureReason}</p>
<div class="buttons">
<a href="${gitlabJobLogsUrl}" target="_blank" class="silex-button silex-button--primary">View build logs</a>
<a href="${pageUrl}" target="_blank" class="silex-button silex-button--secondary">Settings</a>
</div>
`
job.logs[0].push(`Build failed: ${failureReason}`)
return { success: false, message }
}
// Job still running, wait before next poll
await setTimeout(jobPollInterval)
} catch (e) {
console.error('Error polling GitLab job status:', e.message)
// Don't fail immediately, just wait and retry
await setTimeout(jobPollInterval)
}
}
// Timeout reached
const message = `
<p><strong>Build is taking longer than expected</strong></p>
<p>Your website may still be published successfully.</p>
<div class="buttons">
<a href="${gitlabUrl}" target="_blank" class="silex-button silex-button--primary">Check your website</a>
<a href="${gitlabJobLogsUrl}" target="_blank" class="silex-button silex-button--secondary">Check build status</a>
</div>
`
job.logs[0].push('Timeout waiting for build completion')
return { success: false, message }
}
async createTag(session: GitlabSession, websiteId: WebsiteId, job: PublicationJobData, { startJob, jobSuccess, jobError }: JobManager): Promise<string | null> {
const projectId = websiteId // Assuming websiteId corresponds to GitLab project ID
const newTag = '_silex_' + Date.now()
// Create a new tag to trigger the build
try {
job.message = 'Triggering build...'
job.logs[0].push(job.message)
await this.callApi({
session,
path: `api/v4/projects/${projectId}/repository/tags`,
method: 'POST',
requestBody: {
tag_name: newTag,
ref: 'main',
message: 'Publication from Silex',
}
})
} catch (error) {
console.error('Error during creating new tag:', error.message)
jobError(job.jobId, `Failed to start build: ${error.message}`)
return null
}
// Return new tag
return newTag
}
}