projects.js (8367B)
1 // src/lib/projects.js - project records 2 // 3 // A project pairs a repository with the settings needed to turn a push into 4 // a run, and with an owner. 5 // 6 // Ownership is what makes it safe for people to bring their own hardware. A 7 // user registers their own projects and their own workers; their workers are 8 // only ever offered their own projects. A project with no owner belongs to 9 // the installation and is administered centrally. 10 // 11 // The trigger secret is the only sensitive field, and it has to be 12 // recoverable rather than hashed because HMAC verification needs the 13 // original value, so it is sealed with the secret box. 14 15 import { newProjectId, slugify } from './ids.js'; 16 import { paginateOffset } from './page.js'; 17 import { parseWorkdir } from './pipeline/parse.js'; 18 import { Problems } from './pipeline/schema.js'; 19 import { canManage, canView, visibleSql } from './visibility.js'; 20 21 export const VISIBILITIES = ['public', 'private']; 22 23 // A git remote selects a transport: ext:: execs, file:// reads local disk, 24 // http:// reaches anything routable from the conductor. 25 const REPO_SCHEMES = new Set(['https:', 'ssh:', 'git:']); 26 const SCP_LIKE = /^[A-Za-z0-9_.+-]+@[A-Za-z0-9_.-]+:(?!\/)/; 27 28 // allowLocalPath: admin CLI only, which already has host access. 29 export function assertRepoUrl(value, { allowLocalPath = false } = {}) { 30 if (typeof value !== 'string' || value.trim().length === 0) { 31 throw new Error('repo_url is required'); 32 } 33 const url = value.trim(); 34 if (url.startsWith('-')) throw new Error('repo_url must not start with -'); 35 if (allowLocalPath && url.startsWith('/')) return url; 36 if (SCP_LIKE.test(url)) return url; 37 38 let parsed; 39 try { 40 parsed = new URL(url); 41 } catch { 42 throw new Error(`repo_url is not a valid url: ${JSON.stringify(url)}`); 43 } 44 if (!REPO_SCHEMES.has(parsed.protocol)) { 45 throw new Error( 46 `repo_url scheme ${JSON.stringify(parsed.protocol)} is not allowed; ` 47 + `use one of ${[...REPO_SCHEMES].join(', ')}` 48 ); 49 } 50 return url; 51 } 52 53 // Validated with the same rule the pipeline uses, so a project and a 54 // repository cannot disagree about what a usable working directory is. 55 export function assertWorkdir(value) { 56 const problems = new Problems(); 57 const parsed = parseWorkdir(problems, 'workdir', value); 58 if (problems.length > 0) { 59 throw new Error(`workdir ${problems.items[0].message}`); 60 } 61 return parsed; 62 } 63 64 const COLUMNS = ` 65 id, name, repo_url, default_branch, config_path, workdir, 66 trigger_secret, enabled, job_counter, owner_id, visibility, 67 artifact_keep_jobs, artifact_keep_days, log_keep_days, 68 created_at, updated_at 69 `; 70 71 export function createProjects({ db, secrets }) { 72 function aad(projectId) { 73 return `project:${projectId}/trigger_secret`; 74 } 75 76 return { 77 async get(id) { 78 return db.get(`SELECT ${COLUMNS} FROM projects WHERE id = {id}`, { id }); 79 }, 80 81 async list() { 82 return db.all(`SELECT ${COLUMNS} FROM projects ORDER BY name`); 83 }, 84 85 async listOwnedBy(ownerId) { 86 return db.all(`SELECT ${COLUMNS} FROM projects WHERE owner_id = {owner} ORDER BY name`, { owner: ownerId }); 87 }, 88 89 async pageVisible(user, { offset, limit }) { 90 return paginateOffset(db, { 91 select: COLUMNS, 92 from: 'projects', 93 ...visibleSql(user, { visibility: 'visibility', owner: 'owner_id' }), 94 order: 'name', 95 offset, 96 limit, 97 }); 98 }, 99 100 async create(input, { allowLocalPath = false } = {}) { 101 const id = input.id ? slugify(input.id) : (slugify(input.name) || newProjectId()); 102 const now = Date.now(); 103 104 if (input.visibility && !VISIBILITIES.includes(input.visibility)) { 105 throw new Error(`visibility must be one of ${VISIBILITIES.join(', ')}`); 106 } 107 const repoUrl = assertRepoUrl(input.repo_url, { allowLocalPath }); 108 109 if (await this.get(id)) throw new Error(`project ${id} already exists`); 110 111 await db.run( 112 `INSERT INTO projects 113 (id, name, repo_url, default_branch, config_path, workdir, 114 trigger_secret, enabled, job_counter, owner_id, visibility, 115 created_at, updated_at) 116 VALUES 117 ({id}, {name}, {repo_url}, {branch}, {config_path}, {workdir}, 118 {secret}, {enabled}, 0, {owner}, {visibility}, 119 {now}, {now})`, 120 { 121 id, 122 name: input.name || id, 123 repo_url: repoUrl, 124 branch: input.default_branch || 'main', 125 config_path: input.config_path || '.conductor.yml', 126 workdir: input.workdir ? assertWorkdir(input.workdir) : null, 127 secret: input.trigger_secret ? secrets.seal(input.trigger_secret, aad(id)) : null, 128 enabled: input.enabled === undefined ? 1 : (input.enabled ? 1 : 0), 129 owner: input.owner_id ?? null, 130 visibility: input.visibility || 'private', 131 now, 132 } 133 ); 134 135 return this.get(id); 136 }, 137 138 triggerSecret(project) { 139 if (!project.trigger_secret) return null; 140 return secrets.open(project.trigger_secret, aad(project.id)); 141 }, 142 143 async setTriggerSecret(id, value) { 144 await db.run( 145 'UPDATE projects SET trigger_secret = {secret}, updated_at = {now} WHERE id = {id}', 146 { id, secret: value === null ? null : secrets.seal(value, aad(id)), now: Date.now() } 147 ); 148 }, 149 150 async setEnabled(id, enabled) { 151 await db.run( 152 'UPDATE projects SET enabled = {enabled}, updated_at = {now} WHERE id = {id}', 153 { id, enabled: enabled ? 1 : 0, now: Date.now() } 154 ); 155 }, 156 157 async setVisibility(id, visibility) { 158 if (!VISIBILITIES.includes(visibility)) { 159 throw new Error(`visibility must be one of ${VISIBILITIES.join(', ')}`); 160 } 161 await db.run( 162 'UPDATE projects SET visibility = {visibility}, updated_at = {now} WHERE id = {id}', 163 { id, visibility, now: Date.now() } 164 ); 165 }, 166 167 // Retention overrides. Null hands the decision back to the server 168 // default; zero means keep forever, and has to stay distinguishable 169 // from null or a project could not opt out of a default that deletes. 170 async setRetention(id, values) { 171 const columns = ['artifact_keep_jobs', 'artifact_keep_days', 'log_keep_days']; 172 const updates = []; 173 const params = { id, now: Date.now() }; 174 175 for (const column of columns) { 176 if (!Object.hasOwn(values, column)) continue; 177 const value = values[column]; 178 179 if (value !== null && (!Number.isInteger(value) || value < 0)) { 180 throw new Error(`${column} must be a whole number of zero or more, or null to follow the server default`); 181 } 182 183 updates.push(`${column} = {${column}}`); 184 params[column] = value; 185 } 186 187 if (updates.length === 0) return; 188 189 await db.run( 190 `UPDATE projects SET ${updates.join(', ')}, updated_at = {now} WHERE id = {id}`, 191 params 192 ); 193 }, 194 195 // Null hands the decision back to the repository, and failing that 196 // to the server default. 197 async setWorkdir(id, workdir) { 198 await db.run( 199 'UPDATE projects SET workdir = {workdir}, updated_at = {now} WHERE id = {id}', 200 { id, workdir: workdir ? assertWorkdir(workdir) : null, now: Date.now() } 201 ); 202 }, 203 204 async setOwner(id, ownerId) { 205 await db.run( 206 'UPDATE projects SET owner_id = {owner}, updated_at = {now} WHERE id = {id}', 207 { id, owner: ownerId ?? null, now: Date.now() } 208 ); 209 }, 210 211 async remove(id) { 212 await db.run('DELETE FROM projects WHERE id = {id}', { id }); 213 }, 214 215 // Allocates the next job number. Must run inside the same transaction 216 // as the job insert, or two concurrent pushes can collide on the 217 // unique (project_id, number) index. 218 async nextJobNumber(tx, projectId) { 219 await tx.run('UPDATE projects SET job_counter = job_counter + 1 WHERE id = {id}', { id: projectId }); 220 const row = await tx.get('SELECT job_counter FROM projects WHERE id = {id}', { id: projectId }); 221 return row.job_counter; 222 }, 223 }; 224 } 225 226 export function canManageProject(user, project) { 227 if (!project) return false; 228 return canManage(user, project.owner_id); 229 } 230 231 export function canViewProject(user, project) { 232 if (!project) return false; 233 return canView(user, { visibility: project.visibility, ownerId: project.owner_id }); 234 }