@silexlabs/silex
Version:
Free and easy website builder for everyone.
279 lines (256 loc) • 11.7 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 in job pages getting */
const SILEX_OVERWRITE_NOTICE = '# silexOverwrite: true'
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}`)
job.message = 'Getting the deployment logs URL...'
job.logs[0].push(job.message)
const gitlabJobLogsUrl = await this.getGitlabJobLogsUrl(session, websiteId, job, { startJob, jobSuccess, jobError }, adminUrl, successTag)
// Because of the GitLab policy, this can be null (and we suggest the user to verify their account)
if (!gitlabJobLogsUrl) {
let errorMessage = 'Could not retrieve the deployment logs URL.'
if (this.isUsingOfficialInstance()) {
const verifyURL = 'https://gitlab.com/-/identity_verification'
errorMessage +=
`<div class="notice">
If your GitLab account is recent, you may need to verify it <a href="${verifyURL}" target="_blank">here</a>
in order to be able to use pipelines (this is GitLab's policy, not Silex's).
</div>`
}
throw new Error(errorMessage)
}
job.logs[0].push(`Deployment logs URL: ${gitlabJobLogsUrl}`)
const message = `
<p><a href="${gitlabUrl}" target="_blank">Your website will soon be live here</a>.</p>
<p>Changes may take a few minutes to appear, <a href="${gitlabJobLogsUrl}" target="_blank">follow deployment here</a>.</p>
<p>Manage <a href="${pageUrl}" target="_blank">GitLab Pages settings</a>.</p>
`
job.logs[0].push(message)
jobSuccess(job.jobId, 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`
}
// waiting for the job corresponding to the current tag
async getGitlabJobLogsUrl(session: GitlabSession, websiteId: WebsiteId, job: PublicationJobData, { startJob, jobSuccess, jobError }: JobManager, projectUrl: string, tag): Promise<string | null> {
const t0 = Date.now()
do {
const jobs = await this.callApi({
session,
path: `api/v4/projects/${websiteId}/jobs`,
method: 'GET'
})
if (!jobs.length) return null
if (jobs[0].ref === tag) {return `${projectUrl}/-/jobs/${jobs[0].id}`}
await setTimeout(waitTimeOut)
} while ((Date.now() - t0) < this.options.timeOut)
// failed in timelaps allowed (avoiding infinite loop)
jobError(job.jobId, 'Failed to get job id')
job.message = 'Unable to get job id'
job.logs[0].push(job.message)
return `${projectUrl}/-/jobs/`
}
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
try {
job.message = `Creating new tag ${newTag}...`
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 create new tag: ${error.message}`)
return null
}
// Return new tag
return newTag
}
}