projects.js (7630B)
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 { parseWorkdir } from './pipeline/parse.js'; 17 import { Problems } from './pipeline/schema.js'; 18 19 export const VISIBILITIES = ['public', 'private']; 20 21 // Validated with the same rule the pipeline uses, so a project and a 22 // repository cannot disagree about what a usable working directory is. 23 export function assertWorkdir(value) { 24 const problems = new Problems(); 25 const parsed = parseWorkdir(problems, 'workdir', value); 26 if (problems.length > 0) { 27 throw new Error(`workdir ${problems.items[0].message}`); 28 } 29 return parsed; 30 } 31 32 const COLUMNS = ` 33 id, name, repo_url, default_branch, config_path, workdir, 34 trigger_secret, enabled, job_counter, owner_id, visibility, 35 artifact_keep_jobs, artifact_keep_days, log_keep_days, 36 created_at, updated_at 37 `; 38 39 export function createProjects({ db, secrets }) { 40 function aad(projectId) { 41 return `project:${projectId}/trigger_secret`; 42 } 43 44 return { 45 async get(id) { 46 return db.get(`SELECT ${COLUMNS} FROM projects WHERE id = {id}`, { id }); 47 }, 48 49 async list() { 50 return db.all(`SELECT ${COLUMNS} FROM projects ORDER BY name`); 51 }, 52 53 // What a given user is allowed to see. Administrators see everything; 54 // everyone else sees what they own plus anything public. 55 async listVisible(user) { 56 if (user && user.role === 'admin') return this.list(); 57 if (user) { 58 return db.all( 59 `SELECT ${COLUMNS} FROM projects 60 WHERE owner_id = {owner} OR visibility = 'public' 61 ORDER BY name`, 62 { owner: user.id } 63 ); 64 } 65 return db.all(`SELECT ${COLUMNS} FROM projects WHERE visibility = 'public' ORDER BY name`); 66 }, 67 68 async listOwnedBy(ownerId) { 69 return db.all(`SELECT ${COLUMNS} FROM projects WHERE owner_id = {owner} ORDER BY name`, { owner: ownerId }); 70 }, 71 72 async create(input) { 73 const id = input.id ? slugify(input.id) : (slugify(input.name) || newProjectId()); 74 const now = Date.now(); 75 76 if (input.visibility && !VISIBILITIES.includes(input.visibility)) { 77 throw new Error(`visibility must be one of ${VISIBILITIES.join(', ')}`); 78 } 79 if (!input.repo_url) throw new Error('repo_url is required'); 80 81 if (await this.get(id)) throw new Error(`project ${id} already exists`); 82 83 await db.run( 84 `INSERT INTO projects 85 (id, name, repo_url, default_branch, config_path, workdir, 86 trigger_secret, enabled, job_counter, owner_id, visibility, 87 created_at, updated_at) 88 VALUES 89 ({id}, {name}, {repo_url}, {branch}, {config_path}, {workdir}, 90 {secret}, {enabled}, 0, {owner}, {visibility}, 91 {now}, {now})`, 92 { 93 id, 94 name: input.name || id, 95 repo_url: input.repo_url, 96 branch: input.default_branch || 'main', 97 config_path: input.config_path || '.conductor.yml', 98 workdir: input.workdir ? assertWorkdir(input.workdir) : null, 99 secret: input.trigger_secret ? secrets.seal(input.trigger_secret, aad(id)) : null, 100 enabled: input.enabled === undefined ? 1 : (input.enabled ? 1 : 0), 101 owner: input.owner_id ?? null, 102 visibility: input.visibility || 'private', 103 now, 104 } 105 ); 106 107 return this.get(id); 108 }, 109 110 triggerSecret(project) { 111 if (!project.trigger_secret) return null; 112 return secrets.open(project.trigger_secret, aad(project.id)); 113 }, 114 115 async setTriggerSecret(id, value) { 116 await db.run( 117 'UPDATE projects SET trigger_secret = {secret}, updated_at = {now} WHERE id = {id}', 118 { id, secret: value === null ? null : secrets.seal(value, aad(id)), now: Date.now() } 119 ); 120 }, 121 122 async setEnabled(id, enabled) { 123 await db.run( 124 'UPDATE projects SET enabled = {enabled}, updated_at = {now} WHERE id = {id}', 125 { id, enabled: enabled ? 1 : 0, now: Date.now() } 126 ); 127 }, 128 129 async setVisibility(id, visibility) { 130 if (!VISIBILITIES.includes(visibility)) { 131 throw new Error(`visibility must be one of ${VISIBILITIES.join(', ')}`); 132 } 133 await db.run( 134 'UPDATE projects SET visibility = {visibility}, updated_at = {now} WHERE id = {id}', 135 { id, visibility, now: Date.now() } 136 ); 137 }, 138 139 // Retention overrides. Null hands the decision back to the server 140 // default; zero means keep forever, and has to stay distinguishable 141 // from null or a project could not opt out of a default that deletes. 142 async setRetention(id, values) { 143 const columns = ['artifact_keep_jobs', 'artifact_keep_days', 'log_keep_days']; 144 const updates = []; 145 const params = { id, now: Date.now() }; 146 147 for (const column of columns) { 148 if (!Object.hasOwn(values, column)) continue; 149 const value = values[column]; 150 151 if (value !== null && (!Number.isInteger(value) || value < 0)) { 152 throw new Error(`${column} must be a whole number of zero or more, or null to follow the server default`); 153 } 154 155 updates.push(`${column} = {${column}}`); 156 params[column] = value; 157 } 158 159 if (updates.length === 0) return; 160 161 await db.run( 162 `UPDATE projects SET ${updates.join(', ')}, updated_at = {now} WHERE id = {id}`, 163 params 164 ); 165 }, 166 167 // Null hands the decision back to the repository, and failing that 168 // to the server default. 169 async setWorkdir(id, workdir) { 170 await db.run( 171 'UPDATE projects SET workdir = {workdir}, updated_at = {now} WHERE id = {id}', 172 { id, workdir: workdir ? assertWorkdir(workdir) : null, now: Date.now() } 173 ); 174 }, 175 176 async setOwner(id, ownerId) { 177 await db.run( 178 'UPDATE projects SET owner_id = {owner}, updated_at = {now} WHERE id = {id}', 179 { id, owner: ownerId ?? null, now: Date.now() } 180 ); 181 }, 182 183 async remove(id) { 184 await db.run('DELETE FROM projects WHERE id = {id}', { id }); 185 }, 186 187 // Allocates the next job number. Must run inside the same transaction 188 // as the job insert, or two concurrent pushes can collide on the 189 // unique (project_id, number) index. 190 async nextJobNumber(tx, projectId) { 191 await tx.run('UPDATE projects SET job_counter = job_counter + 1 WHERE id = {id}', { id: projectId }); 192 const row = await tx.get('SELECT job_counter FROM projects WHERE id = {id}', { id: projectId }); 193 return row.job_counter; 194 }, 195 }; 196 } 197 198 // An unowned project is administered centrally; an owned one is the 199 // owner's, and administrators may act on either. 200 export function canManageProject(user, project) { 201 if (!user || !project) return false; 202 if (user.role === 'admin') return true; 203 return project.owner_id !== null && project.owner_id === user.id; 204 } 205 206 export function canViewProject(user, project) { 207 if (!project) return false; 208 if (project.visibility === 'public') return true; 209 return canManageProject(user, project); 210 }