feat: add patch controls to web, review web, enrich info tab (#115)

* add ability to specify one-time patch on package addition

* support vars in interface
This commit is contained in:
Evgenii Alekseev 2023-10-29 23:41:20 +02:00 committed by GitHub
parent 8524f1eb20
commit 554827cc57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1327 additions and 276 deletions

View File

@ -200,6 +200,14 @@ Alternatively you can create full-diff patches, which are calculated by using ``
The last command will calculate diff from current tree to the ``HEAD`` and will store it locally. Patches will be applied on any package actions (e.g. it can be used for dependency management).
It is also possible to create simple patch during package addition, e.g.:
.. code-block:: shell
sudo -u ahriman ahriman package-add ahriman --variable PKGEXT=.pkg.tar.xz
The ``--variable`` argument accepts variables in shell like format: quotation and lists are supported as usual, but functions are not. This feature is useful in particular in order to override specific makepkg variables during build.
How to build package from official repository
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -51,7 +51,7 @@
</button>
</li>
<li>
<button id="package-update-button" class="btn dropdown-item" onclick="updatePackages()" hidden>
<button id="package-update-button" class="btn dropdown-item" onclick="packagesUpdate()" hidden>
<i class="bi bi-play"></i> update
</button>
</li>
@ -61,7 +61,7 @@
</button>
</li>
<li>
<button id="package-remove-button" class="btn dropdown-item" onclick="removePackages()" disabled hidden>
<button id="package-remove-button" class="btn dropdown-item" onclick="packagesRemove()" disabled hidden>
<i class="bi bi-trash"></i> remove
</button>
</li>
@ -77,7 +77,8 @@
</button>
</div>
<table id="packages" class="table table-striped table-hover"
<table id="packages"
data-classes="table table-hover"
data-export-options='{"fileName": "packages"}'
data-filter-control="true"
data-filter-control-visible="false"
@ -102,13 +103,13 @@
<tr>
<th data-checkbox="true"></th>
<th data-sortable="true" data-switchable="false" data-field="base" data-filter-control="input" data-filter-control-placeholder="(any base)">package base</th>
<th data-sortable="true" data-field="version" data-filter-control="input" data-filter-control-placeholder="(any version)">version</th>
<th data-sortable="true" data-align="right" data-field="version" data-filter-control="input" data-filter-control-placeholder="(any version)">version</th>
<th data-sortable="true" data-field="packages" data-filter-control="input" data-filter-control-placeholder="(any package)">packages</th>
<th data-sortable="true" data-visible="false" data-field="groups" data-filter-control="select" data-filter-data="func:filterListGroups" data-filter-custom-search="filterList" data-filter-control-placeholder="(any group)">groups</th>
<th data-sortable="true" data-visible="false" data-field="licenses" data-filter-control="select" data-filter-data="func:filterListLicenses" data-filter-custom-search="filterList" data-filter-control-placeholder="(any license)">licenses</th>
<th data-sortable="true" data-visible="false" data-field="packager" data-filter-control="select" data-filter-custom-search="filterContains" data-filter-control-placeholder="(any packager)">packager</th>
<th data-sortable="true" data-field="timestamp" data-filter-control="input" data-filter-custom-search="filterDateRange" data-filter-control-placeholder="(any date)">last update</th>
<th data-sortable="true" data-cell-style="statusFormat" data-field="status" data-filter-control="select" data-filter-control-placeholder="(any status)">status</th>
<th data-sortable="true" data-align="right" data-field="timestamp" data-filter-control="input" data-filter-custom-search="filterDateRange" data-filter-control-placeholder="(any date)">last update</th>
<th data-sortable="true" data-align="center" data-cell-style="statusFormat" data-field="status" data-filter-control="select" data-filter-control-placeholder="(any status)">status</th>
</tr>
</thead>
</table>

View File

@ -8,20 +8,20 @@
</div>
<div class="modal-body">
<div class="form-group row">
<label for="key-import-fingerprint-input" class="col-sm-2 col-form-label">fingerprint</label>
<div class="col-sm-10">
<label for="key-import-fingerprint-input" class="col-2 col-form-label">fingerprint</label>
<div class="col-10">
<input id="key-import-fingerprint-input" type="text" class="form-control" placeholder="PGP key fingerprint" name="key" required>
</div>
</div>
<div class="form-group row">
<label for="key-import-server-input" class="col-sm-2 col-form-label">key server</label>
<div class="col-sm-10">
<label for="key-import-server-input" class="col-2 col-form-label">key server</label>
<div class="col-10">
<input id="key-import-server-input" type="text" class="form-control" placeholder="PGP key server" name="server" value="keyserver.ubuntu.com" required>
</div>
</div>
<div class="form-group row">
<div class="col-sm-2"></div>
<div class="col-sm-10">
<div class="col-2"></div>
<div class="col-10">
<pre class="language-less"><samp id="key-import-body-input" class="pre-scrollable language-less"></samp><button id="key-import-copy-button" type="button" class="btn language-less" onclick="copyPgpKey()"><i class="bi bi-clipboard"></i> copy</button></pre>
</div>
</div>

View File

@ -8,14 +8,14 @@
</div>
<div class="modal-body">
<div class="form-group row">
<label for="login-username" class="col-sm-4 col-form-label">username</label>
<div class="col-sm-8">
<label for="login-username" class="col-4 col-form-label">username</label>
<div class="col-8">
<input id="login-username" type="text" class="form-control" placeholder="enter username" name="username" required>
</div>
</div>
<div class="form-group row">
<label for="login-password" class="col-sm-4 col-form-label">password</label>
<div class="col-sm-8">
<label for="login-password" class="col-4 col-form-label">password</label>
<div class="col-8">
<div class="input-group">
<input id="login-password" type="password" class="form-control" placeholder="enter password" name="password" required>
<div class="input-group-append">

View File

@ -1,5 +1,5 @@
<div id="package-add-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<form id="package-add-form" onsubmit="return false">
<div class="modal-header">
@ -8,9 +8,9 @@
</div>
<div class="modal-body">
<div class="form-group row">
<label for="package-add-repository-input" class="col-sm-4 col-form-label">repository</label>
<div class="col-sm-8">
<select id="package-add-repository-input" class="form-control" name="repository" required>
<label for="package-add-repository-input" class="col-3 col-form-label">repository</label>
<div class="col-9">
<select id="package-add-repository-input" class="form-control" required>
{% for repository in repositories %}
<option value="{{ repository.id }}" data-repository="{{ repository.repository }}" data-architecture="{{ repository.architecture }}">{{ repository.repository }} ({{ repository.architecture }})</option>
{% endfor %}
@ -18,12 +18,18 @@
</div>
</div>
<div class="form-group row">
<label for="package-add-input" class="col-sm-4 col-form-label">package</label>
<div class="col-sm-8">
<input id="package-add-input" type="text" list="known-packages-dlist" autocomplete="off" class="form-control" placeholder="AUR package" name="package" required>
<label for="package-add-input" class="col-3 col-form-label">package</label>
<div class="col-9">
<input id="package-add-input" type="text" list="package-add-known-packages-dlist" autocomplete="off" class="form-control" placeholder="AUR package" required>
<datalist id="package-add-known-packages-dlist"></datalist>
</div>
</div>
<div class="form-group row">
<div class="col-12">
<button id="package-add-variable-button" type="button" class="form-control btn btn-light rounded" onclick="packageAddVariableInputCreate()"><i class="bi bi-plus"></i> add environment variable </button>
</div>
</div>
<div id="package-add-variables-div" class="form-group row"></div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" onclick="packagesAdd()"><i class="bi bi-play"></i> add</button>
@ -39,9 +45,11 @@
const packageAddForm = $("#package-add-form");
packageAddModal.on("shown.bs.modal", () => {
$(`#package-add-repository-input option[value="${repository.architecture}-${repository.repository}"]`).prop("selected", true);
});
packageAddModal.on("hidden.bs.modal", () => { packageAddForm.trigger("reset"); });
packageAddModal.on("hidden.bs.modal", () => {
packageAddVariablesDiv.empty();
packageAddForm.trigger("reset");
});
const packageAddInput = $("#package-add-input");
const packageAddRepositoryInput = $("#package-add-repository-input");
@ -71,25 +79,81 @@
}, this), 500));
});
function packagesAdd() {
const packages = packageAddInput.val();
const packageAddVariablesDiv = $("#package-add-variables-div");
function packageAddVariableInputCreate() {
const variableInput = document.createElement("div");
variableInput.classList.add("input-group");
variableInput.classList.add("package-add-variable");
const variableNameInput = document.createElement("input");
variableNameInput.type = "text";
variableNameInput.classList.add("form-control");
variableNameInput.classList.add("package-add-variable-name");
variableNameInput.placeholder = "name";
variableNameInput.ariaLabel = "variable name";
const variableSeparator = document.createElement("span");
variableSeparator.classList.add("input-group-text")
variableSeparator.textContent = "=";
const variableValueInput = document.createElement("input");
variableValueInput.type = "text";
variableValueInput.classList.add("form-control");
variableValueInput.classList.add("package-add-variable-value");
variableValueInput.placeholder = "value";
variableValueInput.ariaLabel = "variable value";
const variableButtonRemove = document.createElement("button");
variableButtonRemove.type = "button";
variableButtonRemove.classList.add("btn");
variableButtonRemove.classList.add("btn-outline-danger");
variableButtonRemove.innerHTML = "<i class=\"bi bi-trash\"></i>";
variableButtonRemove.onclick = _ => { return variableInput.remove(); };
// bring them together
variableInput.appendChild(variableNameInput);
variableInput.appendChild(variableSeparator);
variableInput.appendChild(variableValueInput);
variableInput.appendChild(variableButtonRemove);
packageAddVariablesDiv.append(variableInput);
}
function patchesParse() {
const patches = packageAddVariablesDiv.find(".package-add-variable").map((_, element) => {
const richElement = $(element);
return {
key: richElement.find(".package-add-variable-name").val(),
value: richElement.find(".package-add-variable-value").val(),
};
}).filter((_, patch) => patch.key).get();
return {patches: patches};
}
function packagesAdd(packages, patches) {
packages = packages ?? packageAddInput.val();
patches = patches ?? patchesParse();
const repository = getRepositorySelector(packageAddRepositoryInput);
if (packages) {
packageAddModal.modal("hide");
const onSuccess = update => `Packages ${update} have been added`;
const onFailure = error => `Package addition failed: ${error}`;
doPackageAction("/api/v1/service/add", [packages], repository, onSuccess, onFailure);
doPackageAction("/api/v1/service/add", [packages], repository, onSuccess, onFailure, patches);
}
}
function packagesRequest() {
const packages = packageAddInput.val();
function packagesRequest(packages, patches) {
packages = packages ?? packageAddInput.val();
patches = patches ?? patchesParse();
const repository = getRepositorySelector(packageAddRepositoryInput);
if (packages) {
packageAddModal.modal("hide");
const onSuccess = update => `Packages ${update} have been requested`;
const onFailure = error => `Package request failed: ${error}`;
doPackageAction("/api/v1/service/request", [packages], repository, onSuccess, onFailure);
doPackageAction("/api/v1/service/request", [packages], repository, onSuccess, onFailure, patches);
}
}
</script>

View File

@ -6,10 +6,47 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<div class="form-group row">
<div class="input-group">
<table class="table table-borderless">
<tbody>
<tr>
<td style="width: 10%; text-align: right">version</td>
<td id="package-info-version" style="width: 40%"></td>
<td style="width: 10%; text-align: right">packager</td>
<td id="package-info-packager" style="width: 40%"></td>
</tr>
<tr>
<td style="width: 10%; text-align: right">groups</td>
<td id="package-info-groups" style="width: 40%"></td>
<td style="width: 10%; text-align: right">licenses</td>
<td id="package-info-licenses" style="width: 40%"></td>
</tr>
<tr>
<td style="width: 10%; text-align: right">packages</td>
<td id="package-info-packages" style="width: 40%"></td>
<td style="width: 10%; text-align: right">depends</td>
<td id="package-info-depends" style="width: 40%"></td>
</tr>
</tbody>
</table>
</div>
</div>
<hr class="col-12">
<h3>Environment variables</h3>
<div id="package-info-variables-div" class="form-group row"></div>
<hr class="col-12">
<h3>Build logs</h3>
<pre class="language-logs"><samp id="package-info-logs-input" class="pre-scrollable language-logs"></samp><button id="package-info-logs-copy-button" type="button" class="btn language-logs" onclick="copyLogs()"><i class="bi bi-clipboard"></i> copy</button></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="showLogs()"><i class="bi bi-arrow-clockwise"></i> reload</button>
<button type="submit" class="btn btn-success" onclick="packageInfoUpdate()" data-bs-dismiss="modal"><i class="bi bi-play"></i> update</button>
<button type="submit" class="btn btn-danger" onclick="packageInfoRemove()" data-bs-dismiss="modal"><i class="bi bi-trash"></i> remove</button>
<button type="button" class="btn btn-secondary" onclick="showPackageInfo()"><i class="bi bi-arrow-clockwise"></i> reload</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal"><i class="bi bi-x"></i> close</button>
</div>
</div>
@ -20,30 +57,78 @@
const packageInfoModal = $("#package-info-modal");
const packageInfoModalHeader = $("#package-info-modal-header");
const packageInfo = $("#package-info");
packageInfoModal.on("hidden.bs.modal", () => {
packageInfoDepends.empty();
packageInfoGroups.empty();
packageInfoLicenses.empty();
packageInfoPackager.empty();
packageInfoPackages.empty();
packageInfoVersion.empty();
packageInfoVariablesDiv.empty();
packageInfoModal.trigger("reset");
});
const packageInfoLogsInput = $("#package-info-logs-input");
const packageInfoLogsCopyButton = $("#package-info-logs-copy-button");
const packageInfoDepends = $("#package-info-depends");
const packageInfoGroups = $("#package-info-groups");
const packageInfoLicenses = $("#package-info-licenses");
const packageInfoPackager = $("#package-info-packager");
const packageInfoPackages = $("#package-info-packages");
const packageInfoVersion = $("#package-info-version");
const packageInfoVariablesDiv = $("#package-info-variables-div");
async function copyLogs() {
const logs = packageInfoLogsInput.text();
await copyToClipboard(logs, packageInfoLogsCopyButton);
}
function showLogs(packageBase) {
const isPackageBaseSet = packageBase !== undefined;
if (isPackageBaseSet)
packageInfoModal.data("package", packageBase); // set package base as currently used
else
packageBase = packageInfoModal.data("package"); // read package base from the current window attribute
function insertVariable(packageBase, variable) {
const variableInput = document.createElement("div");
variableInput.classList.add("input-group");
const headerClass = status => {
if (status === "pending") return ["bg-warning"];
if (status === "building") return ["bg-warning"];
if (status === "failed") return ["bg-danger", "text-white"];
if (status === "success") return ["bg-success", "text-white"];
return ["bg-secondary", "text-white"];
const variableNameInput = document.createElement("input");
variableNameInput.classList.add("form-control");
variableNameInput.readOnly = true;
variableNameInput.value = variable.key;
const variableSeparator = document.createElement("span");
variableSeparator.classList.add("input-group-text")
variableSeparator.textContent = "=";
const variableValueInput = document.createElement("input");
variableValueInput.classList.add("form-control");
variableValueInput.readOnly = true;
variableValueInput.value = variable.value;
const variableButtonRemove = document.createElement("button");
variableButtonRemove.type = "button";
variableButtonRemove.classList.add("btn");
variableButtonRemove.classList.add("btn-outline-danger");
variableButtonRemove.innerHTML = "<i class=\"bi bi-trash\"></i>";
variableButtonRemove.onclick = _ => {
$.ajax({
url: `/api/v1/packages/${packageBase}/patches/${variable.key}`,
type: "DELETE",
dataType: "json",
success: _ => variableInput.remove(),
});
};
// bring them together
variableInput.appendChild(variableNameInput);
variableInput.appendChild(variableSeparator);
variableInput.appendChild(variableValueInput);
variableInput.appendChild(variableButtonRemove);
packageInfoVariablesDiv.append(variableInput);
}
function loadLogs(packageBase, onFailure) {
$.ajax({
url: `/api/v2/packages/${packageBase}/logs`,
data: {
@ -53,26 +138,101 @@
type: "GET",
dataType: "json",
success: response => {
packageInfo.text(`${response.package_base} ${response.status.status} at ${new Date(1000 * response.status.timestamp).toISOString()}`);
const logs = response.logs.map(log_record => {
const [timestamp, record] = log_record;
return `[${new Date(1000 * timestamp).toISOString()}] ${record}`;
const logs = response.map(log_record => {
return `[${new Date(1000 * log_record.created).toISOString()}] ${log_record.message}`;
});
packageInfoLogsInput.text(logs.join("\n"));
},
error: onFailure,
});
}
function loadPackage(packageBase, onFailure) {
const headerClass = status => {
if (status === "pending") return ["bg-warning"];
if (status === "building") return ["bg-warning"];
if (status === "failed") return ["bg-danger", "text-white"];
if (status === "success") return ["bg-success", "text-white"];
return ["bg-secondary", "text-white"];
};
$.ajax({
url: `/api/v1/packages/${packageBase}`,
data: {
architecture: repository.architecture,
repository: repository.repository,
},
type: "GET",
dataType: "json",
success: response => {
const description = response.find(Boolean);
const packages = Object.keys(description.package.packages);
packageInfo.text(`${description.package.base} ${description.status.status} at ${new Date(1000 * description.status.timestamp).toISOStringShort()}`);
packageInfoModalHeader.removeClass();
packageInfoModalHeader.addClass("modal-header");
headerClass(response.status.status).forEach((clz) => packageInfoModalHeader.addClass(clz));
headerClass(description.status.status).forEach(clz => packageInfoModalHeader.addClass(clz));
if (isPackageBaseSet) packageInfoModal.modal("show"); // we don't need to show window again
},
error: (jqXHR, _, errorThrown) => {
// show failed modal in case if first time loading
if (isPackageBaseSet) {
const message = error => `Could not load package ${packageBase} logs: ${error}`;
showFailure("Load failure", message, jqXHR, errorThrown);
}
packageInfoDepends.html(listToTable(
Object.values(description.package.packages)
.reduce((accumulator, currentValue) => {
return accumulator.concat(currentValue.depends.filter(v => packages.indexOf(v) === -1))
.concat(currentValue.make_depends.filter(v => packages.indexOf(v) === -1).map(v => `${v} (make)`))
.concat(currentValue.opt_depends.filter(v => packages.indexOf(v) === -1).map(v => `${v} (optional)`));
}, [])
));
packageInfoGroups.html(listToTable(extractListProperties(description.package, "groups")));
packageInfoLicenses.html(listToTable(extractListProperties(description.package, "licenses")));
packageInfoPackager.text(description.package.packager);
packageInfoPackages.html(listToTable(packages));
packageInfoVersion.text(description.package.version);
},
error: onFailure,
});
}
function loadPatches(packageBase, onFailure) {
$.ajax({
url: `/api/v1/packages/${packageBase}/patches`,
type: "GET",
dataType: "json",
success: response => {
packageInfoVariablesDiv.empty();
response.map(patch => insertVariable(packageBase, patch));
},
error: onFailure,
});
}
function packageInfoRemove() {
const packageBase = packageInfoModal.data("package");
if (packageBase) return packagesRemove([packageBase]);
}
function packageInfoUpdate() {
const packageBase = packageInfoModal.data("package");
if (packageBase) return packagesAdd(packageBase, []);
}
function showPackageInfo(packageBase) {
const isPackageBaseSet = packageBase !== undefined;
if (isPackageBaseSet)
packageInfoModal.data("package", packageBase); // set package base as currently used
else
packageBase = packageInfoModal.data("package"); // read package base from the current window attribute
const onFailure = (jqXHR, _, errorThrown) => {
if (isPackageBaseSet) {
const message = error => `Could not load package ${packageBase} info: ${error}`;
showFailure("Load failure", message, jqXHR, errorThrown);
}
};
loadPackage(packageBase, onFailure);
loadPatches(packageBase, onFailure);
loadLogs(packageBase, onFailure);
if (isPackageBaseSet) packageInfoModal.modal("show");
}
</script>

View File

@ -1,5 +1,5 @@
<div id="package-rebuild-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<form id="package-rebuild-form" onsubmit="return false">
<div class="modal-header">
@ -8,8 +8,8 @@
</div>
<div class="modal-body">
<div class="form-group row">
<label for="package-rebuild-repository-input" class="col-sm-4 col-form-label">repository</label>
<div class="col-sm-8">
<label for="package-rebuild-repository-input" class="col-3 col-form-label">repository</label>
<div class="col-9">
<select id="package-rebuild-repository-input" class="form-control" name="repository" required>
{% for repository in repositories %}
<option value="{{ repository.id }}" data-repository="{{ repository.repository }}" data-architecture="{{ repository.architecture }}">{{ repository.repository }} ({{ repository.architecture }})</option>
@ -18,8 +18,8 @@
</div>
</div>
<div class="form-group row">
<label for="package-rebuild-dependency-input" class="col-sm-4 col-form-label">dependency</label>
<div class="col-sm-8">
<label for="package-rebuild-dependency-input" class="col-3 col-form-label">dependency</label>
<div class="col-9">
<input id="package-rebuild-dependency-input" type="text" class="form-control" placeholder="packages dependency" name="package" required>
</div>
</div>

View File

@ -25,7 +25,7 @@
if (0 === cell || "base" === cell) {
const method = data[0] === true ? "uncheckBy" : "checkBy"; // fck javascript
table.bootstrapTable(method, {field: "id", values: [data.id]});
} else showLogs(data.id);
} else showPackageInfo(data.id);
});
table.on("created-controls.bs.table", () => {
const pickerInput = $(".bootstrap-table-filter-control-timestamp");
@ -50,7 +50,7 @@
const statusBadge = $("#badge-status");
const versionBadge = $("#badge-version");
function doPackageAction(uri, packages, repository, successText, failureText) {
function doPackageAction(uri, packages, repository, successText, failureText, data) {
const queryParams = $.param({
architecture: repository.architecture,
repository: repository.repository,
@ -58,7 +58,7 @@
$.ajax({
url: `${uri}?${queryParams}`,
data: JSON.stringify({packages: packages}),
data: JSON.stringify(Object.assign({}, {packages: packages}, data || {})),
type: "POST",
contentType: "application/json",
success: _ => {
@ -71,31 +71,30 @@
});
}
function filterListGroups() {
return extractDataList(table.bootstrapTable("getData"), "groups");
}
function filterListLicenses() {
return extractDataList(table.bootstrapTable("getData"), "licenses");
}
function filterListPackagers() {
return extractDataList(table.bootstrapTable("getData"), "packager");
}
function getRepositorySelector(selector) {
const selected = selector.find(":selected");
return {
architecture: selected.data("architecture"),
repository: selected.data("repository"),
};
}
function getSelection() {
return table.bootstrapTable("getSelections").map(row => row.id);
}
function removePackages() {
const onSuccess = update => `Packages ${update} have been removed`;
const onFailure = error => `Could not remove packages: ${error}`;
doPackageAction("/api/v1/service/remove", getSelection(), repository, onSuccess, onFailure);
}
function selectRepository() {
const fragment = window.location.hash.replace("#", "") || "{{ repositories[0].id }}";
const element = $(`#${fragment}-lnk`);
element.click();
}
function updatePackages() {
const currentSelection = getSelection();
const [url, onSuccess] = currentSelection.length === 0
? ["/api/v1/service/update", _ => `Repository update has been run`]
: ["/api/v1/service/add", update => `Run update for packages ${update}`];
const onFailure = error => `Packages update failed: ${error}`;
doPackageAction(url, currentSelection, repository, onSuccess, onFailure);
}
function hideControls(hidden) {
keyImportButton.attr("hidden", hidden);
packageAddButton.attr("hidden", hidden);
@ -104,6 +103,24 @@
packageUpdateButton.attr("hidden", hidden);
}
function packagesRemove(packages) {
packages = packages ?? getSelection();
const onSuccess = update => `Packages ${update} have been removed`;
const onFailure = error => `Could not remove packages: ${error}`;
doPackageAction("/api/v1/service/remove", packages, repository, onSuccess, onFailure);
}
function packagesUpdate() {
const currentSelection = getSelection();
const [url, onSuccess] = currentSelection.length === 0
? ["/api/v1/service/update", _ => `Repository update has been run`]
: ["/api/v1/service/add", update => `Run update for packages ${update}`];
const onFailure = error => `Packages update failed: ${error}`;
doPackageAction(url, currentSelection, repository, onSuccess, onFailure);
}
function reload() {
table.bootstrapTable("showLoading");
@ -124,18 +141,6 @@
type: "GET",
dataType: "json",
success: response => {
const extractListProperties = (description, property) => {
return Object.values(description.packages)
.map(pkg => pkg[property])
.reduce((left, right) => left.concat(right), []);
};
const listToTable = data => {
return Array.from(new Set(data))
.sort()
.map(entry => safe(entry))
.join("<br>");
};
const payload = response.map(description => {
const package_base = description.package.base;
const web_url = description.package.remote.web_url;
@ -147,7 +152,7 @@
packages: listToTable(Object.keys(description.package.packages)),
groups: listToTable(extractListProperties(description.package, "groups")),
licenses: listToTable(extractListProperties(description.package, "licenses")),
timestamp: new Date(1000 * description.status.timestamp).toISOString(),
timestamp: new Date(1000 * description.status.timestamp).toISOStringShort(),
status: description.status.status,
};
});
@ -186,7 +191,7 @@
statusBadge
.popover("dispose")
.attr("data-bs-content", `${response.status.status} at ${new Date(1000 * response.status.timestamp).toISOString()}`)
.attr("data-bs-content", `${response.status.status} at ${new Date(1000 * response.status.timestamp).toISOStringShort()}`)
.popover();
statusBadge.removeClass();
statusBadge.addClass("btn");
@ -195,6 +200,12 @@
});
}
function selectRepository() {
const fragment = window.location.hash.replace("#", "") || "{{ repositories[0].id }}";
const element = $(`#${fragment}-lnk`);
element.click();
}
function statusFormat(value) {
const cellClass = status => {
if (status === "pending") return "table-warning";
@ -206,26 +217,6 @@
return {classes: cellClass(value)};
}
function filterListGroups() {
return extractDataList(table.bootstrapTable("getData"), "groups");
}
function filterListLicenses() {
return extractDataList(table.bootstrapTable("getData"), "licenses");
}
function filterListPackagers() {
return extractDataList(table.bootstrapTable("getData"), "packager");
}
function getRepositorySelector(selector) {
const selected = selector.find(":selected");
return {
architecture: selected.data("architecture"),
repository: selected.data("repository"),
};
}
$(() => {
table.bootstrapTable({});
statusBadge.popover();

View File

@ -30,7 +30,8 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
</div>
<div class="container">
<table id="packages" class="table table-striped table-hover"
<table id="packages"
data-classes="table table-hover"
data-export-options='{"fileName": "packages"}'
data-filter-control="true"
data-filter-control-visible="false"
@ -53,16 +54,16 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
<thead class="table-primary">
<tr>
<th data-sortable="true" data-switchable="false" data-field="name" data-filter-control="input" data-filter-control-placeholder="(any package)">package</th>
<th data-sortable="true" data-field="version" data-filter-control="input" data-filter-control-placeholder="(any version)">version</th>
<th data-sortable="true" data-align="right" data-field="version" data-filter-control="input" data-filter-control-placeholder="(any version)">version</th>
<th data-sortable="true" data-visible="false" data-field="architecture" data-filter-control="select" data-filter-control-placeholder="(any arch)">architecture</th>
<th data-sortable="true" data-visible="false" data-field="description" data-filter-control="input" data-filter-control-placeholder="(any description)">description</th>
<th data-sortable="true" data-visible="false" data-field="url">upstream url</th>
<th data-sortable="true" data-visible="false" data-field="licenses" data-filter-control="select" data-filter-data="func:filterListLicenses" data-filter-custom-search="filterList" data-filter-control-placeholder="(any license)">licenses</th>
<th data-sortable="true" data-visible="false" data-field="groups" data-filter-control="select" data-filter-data="func:filterListGroups" data-filter-custom-search="filterList" data-filter-control-placeholder="(any group)">groups</th>
<th data-sortable="true" data-visible="false" data-field="depends" data-filter-control="select" data-filter-data="func:filterListDepends" data-filter-custom-search="filterList" data-filter-control-placeholder="(any depends)">depends</th>
<th data-sortable="true" data-field="archive_size">archive size</th>
<th data-sortable="true" data-field="installed_size">installed size</th>
<th data-sortable="true" data-field="timestamp" data-filter-control="input" data-filter-custom-search="filterDateRange" data-filter-control-placeholder="(any date)">build date</th>
<th data-sortable="true" data-align="right" data-field="archive_size">archive size</th>
<th data-sortable="true" data-align="right" data-field="installed_size">installed size</th>
<th data-sortable="true" data-align="right" data-field="timestamp" data-filter-control="input" data-filter-custom-search="filterDateRange" data-filter-control-placeholder="(any date)">build date</th>
</tr>
</thead>

View File

@ -36,19 +36,17 @@
}, 2000);
}
function safe(string) {
return String(string)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function extractDataList(data, column) {
const elements = data.flatMap(row => row[column].split("<br>")).filter(v => v); // remove empty elements from array
return Array.from(new Set(elements)).sort();
}
function extractListProperties(description, property) {
return Object.values(description.packages)
.map(pkg => pkg[property])
.reduce((left, right) => left.concat(right), []);
}
function filterContains(text, value) {
return value.includes(text.toLowerCase().trim());
}
@ -67,4 +65,24 @@
// the library removes all symbols from string, so it is just string
return value.includes(dataList[index].toLowerCase());
}
function listToTable(data) {
return Array.from(new Set(data))
.sort()
.map(entry => safe(entry))
.join("<br>");
}
function safe(string) {
return String(string)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
Date.prototype.toISOStringShort = function() {
const pad = number => String(number).padStart(2, "0");
return `${this.getFullYear()}-${pad(this.getMonth())}-${pad(this.getDate())} ${pad(this.getHours())}:${pad(this.getMinutes())}:${pad(this.getSeconds())}`;
}
</script>

View File

@ -276,6 +276,7 @@ def _set_package_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("-s", "--source", help="explicitly specify the package source for this command",
type=PackageSource, choices=enum_values(PackageSource), default=PackageSource.Auto)
parser.add_argument("-u", "--username", help="build as user", default=extract_user())
parser.add_argument("-v", "--variable", help="apply specified makepkg variables to the next build", action="append")
parser.set_defaults(handler=handlers.Add)
return parser

View File

@ -23,6 +23,7 @@ from ahriman.application.application import Application
from ahriman.application.handlers import Handler
from ahriman.core.configuration import Configuration
from ahriman.models.packagers import Packagers
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.repository_id import RepositoryId
@ -45,7 +46,12 @@ class Add(Handler):
"""
application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh)
application.on_start()
application.add(args.package, args.source, args.username)
patches = [PkgbuildPatch.from_env(patch) for patch in args.variable] if args.variable is not None else []
for package in args.package: # for each requested package insert patch
application.database.patches_insert(package, patches)
if not args.now:
return

View File

@ -162,7 +162,7 @@ class Handler:
if args.repository_id is not None:
separator = "/" if "/" in args.repository_id else "-" # systemd and non-systemd identifiers
# repository parts is optional for backward compatibility
architecture, *repository_parts = args.repository_id.split(separator)
architecture, *repository_parts = args.repository_id.split(separator) # maxsplit isn't used intentionally
args.architecture = architecture
if repository_parts:
args.repository = "-".join(repository_parts) # replace slash with dash

View File

@ -115,7 +115,7 @@ class Patch(Handler):
package_base(str): package base
patch(PkgbuildPatch): patch descriptor
"""
application.database.patches_insert(package_base, patch)
application.database.patches_insert(package_base, [patch])
@staticmethod
def patch_set_list(application: Application, package_base: str | None, variables: list[str] | None,

View File

@ -33,8 +33,8 @@ class PackageOperations(Operations):
package operations
"""
def _package_remove_package_base(self, connection: Connection, package_base: str,
repository_id: RepositoryId) -> None:
@staticmethod
def _package_remove_package_base(connection: Connection, package_base: str, repository_id: RepositoryId) -> None:
"""
remove package base information
@ -50,8 +50,9 @@ class PackageOperations(Operations):
"""delete from package_bases where package_base = :package_base and repository = :repository""",
{"package_base": package_base, "repository": repository_id.id})
def _package_remove_packages(self, connection: Connection, package_base: str,
current_packages: Iterable[str], repository_id: RepositoryId) -> None:
@staticmethod
def _package_remove_packages(connection: Connection, package_base: str, current_packages: Iterable[str],
repository_id: RepositoryId) -> None:
"""
remove packages belong to the package base
@ -74,8 +75,8 @@ class PackageOperations(Operations):
"""delete from packages where package = :package and repository = :repository""",
packages)
def _package_update_insert_base(self, connection: Connection, package: Package,
repository_id: RepositoryId) -> None:
@staticmethod
def _package_update_insert_base(connection: Connection, package: Package, repository_id: RepositoryId) -> None:
"""
insert base package into table
@ -107,8 +108,8 @@ class PackageOperations(Operations):
}
)
def _package_update_insert_packages(self, connection: Connection, package: Package,
repository_id: RepositoryId) -> None:
@staticmethod
def _package_update_insert_packages(connection: Connection, package: Package, repository_id: RepositoryId) -> None:
"""
insert packages into table
@ -149,7 +150,8 @@ class PackageOperations(Operations):
""",
package_list)
def _package_update_insert_status(self, connection: Connection, package_base: str, status: BuildStatus,
@staticmethod
def _package_update_insert_status(connection: Connection, package_base: str, status: BuildStatus,
repository_id: RepositoryId) -> None:
"""
insert base package status into table
@ -176,8 +178,8 @@ class PackageOperations(Operations):
"repository": repository_id.id,
})
def _packages_get_select_package_bases(self, connection: Connection,
repository_id: RepositoryId) -> dict[str, Package]:
@staticmethod
def _packages_get_select_package_bases(connection: Connection, repository_id: RepositoryId) -> dict[str, Package]:
"""
select package bases from the table
@ -201,7 +203,8 @@ class PackageOperations(Operations):
)
}
def _packages_get_select_packages(self, connection: Connection, packages: dict[str, Package],
@staticmethod
def _packages_get_select_packages(connection: Connection, packages: dict[str, Package],
repository_id: RepositoryId) -> dict[str, Package]:
"""
select packages from the table
@ -223,8 +226,8 @@ class PackageOperations(Operations):
packages[row["package_base"]].packages[row["package"]] = PackageDescription.from_json(row)
return packages
def _packages_get_select_statuses(self, connection: Connection,
repository_id: RepositoryId) -> dict[str, BuildStatus]:
@staticmethod
def _packages_get_select_statuses(connection: Connection, repository_id: RepositoryId) -> dict[str, BuildStatus]:
"""
select package build statuses from the table

View File

@ -18,7 +18,6 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from collections import defaultdict
from sqlite3 import Connection
from ahriman.core.database.operations import Operations
@ -42,16 +41,16 @@ class PatchOperations(Operations):
"""
return self.patches_list(package_base, None).get(package_base, [])
def patches_insert(self, package_base: str, patch: PkgbuildPatch) -> None:
def patches_insert(self, package_base: str, patches: list[PkgbuildPatch]) -> None:
"""
insert or update patch in database
Args:
package_base(str): package base to insert
patch(PkgbuildPatch): patch content
patches(list[PkgbuildPatch]): patch content
"""
def run(connection: Connection) -> None:
connection.execute(
connection.executemany(
"""
insert into patches
(package_base, variable, patch)
@ -60,7 +59,14 @@ class PatchOperations(Operations):
on conflict (package_base, coalesce(variable, '')) do update set
patch = :patch
""",
{"package_base": package_base, "variable": patch.key, "patch": patch.value})
[
{
"package_base": package_base,
"variable": patch.key,
"patch": patch.value,
} for patch in patches
]
)
return self.with_connection(run, commit=True)
@ -89,7 +95,7 @@ class PatchOperations(Operations):
if variables is not None and patch.key not in variables:
continue
patches[package].append(patch)
return dict(patches)
return patches
def patches_remove(self, package_base: str, variables: list[str] | None) -> None:
"""
@ -102,12 +108,21 @@ class PatchOperations(Operations):
def run_many(connection: Connection) -> None:
patches = variables or [] # suppress mypy warning
connection.executemany(
"""delete from patches where package_base = :package_base and variable = :variable""",
[{"package_base": package_base, "variable": variable} for variable in patches])
"""
delete from patches where package_base = :package_base and variable = :variable
""",
[
{
"package_base": package_base,
"variable": variable,
} for variable in patches
])
def run(connection: Connection) -> None:
connection.execute(
"""delete from patches where package_base = :package_base""",
"""
delete from patches where package_base = :package_base
""",
{"package_base": package_base})
if variables is not None:

View File

@ -28,6 +28,7 @@ from multiprocessing import Process, Queue
from threading import Lock, Thread
from ahriman.core.log import LazyLogging
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.process_status import ProcessStatus
from ahriman.models.repository_id import RepositoryId
@ -96,7 +97,8 @@ class Spawn(Thread, LazyLogging):
queue.put(ProcessStatus(process_id, result, consumed_time))
def _spawn_process(self, repository_id: RepositoryId, command: str, *args: str, **kwargs: str | None) -> str:
def _spawn_process(self, repository_id: RepositoryId, command: str, *args: str,
**kwargs: str | list[str] | None) -> str:
"""
spawn external ahriman process with supplied arguments
@ -104,7 +106,7 @@ class Spawn(Thread, LazyLogging):
repository_id(RepositoryId): repository unique identifier
command(str): subcommand to run
*args(str): positional command arguments
**kwargs(str): named command arguments
**kwargs(str | list[str] | None): named command arguments
Returns:
str: spawned process identifier
@ -118,9 +120,13 @@ class Spawn(Thread, LazyLogging):
for argument, value in kwargs.items():
if value is None:
continue # skip null values
arguments.append(f"--{argument}")
if value:
arguments.append(value)
flag = f"--{argument}"
if isinstance(value, list):
arguments.extend(list(sum(((flag, v) for v in value), ())))
elif value:
arguments.extend([flag, value])
else:
arguments.append(flag) # boolean argument
process_id = str(uuid.uuid4())
self.logger.info("full command line arguments of %s are %s using repository %s",
@ -167,7 +173,7 @@ class Spawn(Thread, LazyLogging):
return self._spawn_process(repository_id, "service-key-import", key, **kwargs)
def packages_add(self, repository_id: RepositoryId, packages: Iterable[str], username: str | None, *,
now: bool) -> str:
patches: list[PkgbuildPatch], now: bool) -> str:
"""
add packages
@ -175,14 +181,18 @@ class Spawn(Thread, LazyLogging):
repository_id(RepositoryId): repository unique identifier
packages(Iterable[str]): packages list to add
username(str | None): optional override of username for build process
patches(list[PkgbuildPatch]): list of patches to be passed
now(bool): build packages now
Returns:
str: spawned process identifier
"""
kwargs = {"username": username}
kwargs: dict[str, str | list[str] | None] = {"username": username}
if now:
kwargs["now"] = ""
if patches:
kwargs["variable"] = [patch.serialize() for patch in patches]
return self._spawn_process(repository_id, "package-add", *packages, **kwargs)
def packages_rebuild(self, repository_id: RepositoryId, depends_on: str, username: str | None) -> str:

View File

@ -23,6 +23,7 @@ from ahriman.core.log import LazyLogging
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.repository_id import RepositoryId
@ -162,6 +163,40 @@ class Watcher(LazyLogging):
self.known[package_base] = (package, full_status)
self.database.package_update(package, full_status, self.repository_id)
def patches_get(self, package_base: str, variable: str | None) -> list[PkgbuildPatch]:
"""
get patches for the package
Args:
package_base(str): package base
variable(str | None): patch variable name if any
Returns:
list[PkgbuildPatch]: list of patches which are stored for the package
"""
variables = [variable] if variable is not None else None
return self.database.patches_list(package_base, variables).get(package_base, [])
def patches_remove(self, package_base: str, variable: str) -> None:
"""
remove package patch
Args:
package_base(str): package base
variable(str): patch variable name
"""
self.database.patches_remove(package_base, [variable])
def patches_update(self, package_base: str, patch: PkgbuildPatch) -> None:
"""
update package patch
Args:
package_base(str): package base
patch(PkgbuildPatch): package patch
"""
self.database.patches_insert(package_base, [patch])
def status_update(self, status: BuildStatusEnum) -> None:
"""
update service status

View File

@ -56,6 +56,7 @@ __all__ = [
"srcinfo_property",
"srcinfo_property_list",
"trim_package",
"unquote",
"utcnow",
"walk",
]
@ -465,6 +466,38 @@ def trim_package(package_name: str) -> str:
return package_name
def unquote(source: str) -> str:
"""
like ``shlex.quote``, but opposite
Args:
source(str): source string to remove quotes
Returns:
str: string with quotes removed
Raises:
ValueError: if no closing quotation
"""
def generator() -> Generator[str, None, None]:
token = None
for char in source:
if token is not None:
if char == token:
token = None # closed quote
else:
yield char # character inside quotes
elif char in ("'", "\""):
token = char # first quote found
else:
yield char # normal character
if token is not None:
raise ValueError("No closing quotation")
return "".join(generator())
def utcnow() -> datetime.datetime:
"""
get current time

View File

@ -19,8 +19,11 @@
#
import shlex
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Self
from ahriman.core.util import dataclass_view, unquote
@dataclass(frozen=True)
@ -33,12 +36,12 @@ class PkgbuildPatch:
considered as full PKGBUILD diffs
value(str | list[str]): value of the stored PKGBUILD property. It must be either string or list of string
values
unsafe(bool): if set, value will be not quoted, might break PKGBUILD
"""
key: str | None
value: str | list[str]
unsafe: bool = field(default=False, kw_only=True)
quote = shlex.quote
def __post_init__(self) -> None:
"""
@ -66,17 +69,26 @@ class PkgbuildPatch:
"""
return self.key is None
def quote(self, value: str) -> str:
@classmethod
def from_env(cls, variable: str) -> Self:
"""
quote value according to the unsafe flag
construct patch from environment variable. Functions are not supported
Args:
value(str): value to be quoted
variable(str): variable in bash form, i.e. KEY=VALUE
Returns:
str: quoted string in case if unsafe is False and as is otherwise
Self: package properties
"""
return value if self.unsafe else shlex.quote(value)
key, *value_parts = variable.split("=", maxsplit=1)
raw_value = next(iter(value_parts), "") # extract raw value
if raw_value.startswith("(") and raw_value.endswith(")"):
value: str | list[str] = shlex.split(raw_value[1:-1]) # arrays for poor
else:
value = unquote(raw_value)
return cls(key, value)
def serialize(self) -> str:
"""
@ -88,14 +100,23 @@ class PkgbuildPatch:
str: serialized key-value pair, print-friendly
"""
if isinstance(self.value, list): # list like
value = " ".join(map(self.quote, self.value))
value = " ".join(map(PkgbuildPatch.quote, self.value))
return f"""{self.key}=({value})"""
if self.is_plain_diff: # no additional logic for plain diffs
return self.value
# we suppose that function values are only supported in string-like values
if self.is_function:
return f"{self.key} {self.value}" # no quoting enabled here
return f"""{self.key}={self.quote(self.value)}"""
return f"""{self.key}={PkgbuildPatch.quote(self.value)}"""
def view(self) -> dict[str, Any]:
"""
generate json patch view
Returns:
dict[str, Any]: json-friendly dictionary
"""
return dataclass_view(self)
def write(self, pkgbuild_path: Path) -> None:
"""

View File

@ -52,6 +52,8 @@ class RepositoryId:
Returns:
str: unique id for this repository
"""
if self.is_empty:
return ""
return f"{self.architecture}-{self.name}" # basically the same as used for command line
def query(self) -> list[tuple[str, str]]:

View File

@ -25,14 +25,17 @@ from ahriman.web.schemas.file_schema import FileSchema
from ahriman.web.schemas.internal_status_schema import InternalStatusSchema
from ahriman.web.schemas.log_schema import LogSchema
from ahriman.web.schemas.login_schema import LoginSchema
from ahriman.web.schemas.logs_schema import LogsSchema, LogsSchemaV2
from ahriman.web.schemas.logs_schema import LogsSchema
from ahriman.web.schemas.oauth2_schema import OAuth2Schema
from ahriman.web.schemas.package_name_schema import PackageNameSchema
from ahriman.web.schemas.package_names_schema import PackageNamesSchema
from ahriman.web.schemas.package_patch_schema import PackagePatchSchema
from ahriman.web.schemas.package_properties_schema import PackagePropertiesSchema
from ahriman.web.schemas.package_schema import PackageSchema
from ahriman.web.schemas.package_status_schema import PackageStatusSimplifiedSchema, PackageStatusSchema
from ahriman.web.schemas.pagination_schema import PaginationSchema
from ahriman.web.schemas.patch_name_schema import PatchNameSchema
from ahriman.web.schemas.patch_schema import PatchSchema
from ahriman.web.schemas.pgp_key_id_schema import PGPKeyIdSchema
from ahriman.web.schemas.pgp_key_schema import PGPKeySchema
from ahriman.web.schemas.process_id_schema import ProcessIdSchema
@ -41,4 +44,5 @@ from ahriman.web.schemas.remote_schema import RemoteSchema
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
from ahriman.web.schemas.search_schema import SearchSchema
from ahriman.web.schemas.status_schema import StatusSchema
from ahriman.web.schemas.versioned_log_schema import VersionedLogSchema
from ahriman.web.schemas.update_flags_schema import UpdateFlagsSchema

View File

@ -17,13 +17,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from marshmallow import fields
from ahriman import __version__
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
from marshmallow import Schema, fields
class LogSchema(RepositoryIdSchema):
class LogSchema(Schema):
"""
request package log schema
"""
@ -32,10 +29,6 @@ class LogSchema(RepositoryIdSchema):
"description": "Log record timestamp",
"example": 1680537091.233495,
})
version = fields.Integer(required=True, metadata={
"description": "Package version to tag",
"example": __version__,
})
message = fields.String(required=True, metadata={
"description": "Log message",
})

View File

@ -27,23 +27,9 @@ class LogsSchema(Schema):
response package logs schema
"""
package_base = fields.String(required=True, metadata={
"description": "Package base name",
"example": "ahriman",
})
status = fields.Nested(StatusSchema(), required=True, metadata={
"description": "Last package status",
})
logs = fields.String(required=True, metadata={
"description": "Full package log from the last build",
})
class LogsSchemaV2(Schema):
"""
response package logs api v2 schema
"""
package_base = fields.String(required=True, metadata={
"description": "Package base name",
"example": "ahriman",
@ -51,7 +37,3 @@ class LogsSchemaV2(Schema):
status = fields.Nested(StatusSchema(), required=True, metadata={
"description": "Last package status",
})
logs = fields.List(fields.Tuple([fields.Float(), fields.String()]), required=True, metadata={ # type: ignore[no-untyped-call]
"description": "Package log records timestamp and message",
"example": [(1680537091.233495, "log record")]
})

View File

@ -0,0 +1,33 @@
#
# Copyright (c) 2021-2023 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from marshmallow import fields
from ahriman.web.schemas.package_names_schema import PackageNamesSchema
from ahriman.web.schemas.patch_schema import PatchSchema
class PackagePatchSchema(PackageNamesSchema):
"""
response schema with packages and patches
"""
patches = fields.Nested(PatchSchema(many=True), metadata={
"description": "optional environment variables to be applied as patches"
})

View File

@ -0,0 +1,33 @@
#
# Copyright (c) 2021-2023 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from marshmallow import fields
from ahriman.web.schemas.package_name_schema import PackageNameSchema
class PatchNameSchema(PackageNameSchema):
"""
request package patch schema
"""
patch = fields.String(required=True, metadata={
"description": "Variable name",
"example": "PKGEXT",
})

View File

@ -0,0 +1,33 @@
#
# Copyright (c) 2021-2023 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from marshmallow import Schema, fields
class PatchSchema(Schema):
"""
request and response patch schema
"""
key = fields.String(required=True, metadata={
"description": "environment variable name",
})
value = fields.String(metadata={
"description": "environment variable value",
})

View File

@ -0,0 +1,35 @@
#
# Copyright (c) 2021-2023 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from marshmallow import fields
from ahriman import __version__
from ahriman.web.schemas.log_schema import LogSchema
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
class VersionedLogSchema(LogSchema, RepositoryIdSchema):
"""
request package log schema
"""
version = fields.Integer(required=True, metadata={
"description": "Package version to tag",
"example": __version__,
})

View File

@ -21,8 +21,9 @@ import aiohttp_apispec # type: ignore[import-untyped]
from aiohttp.web import HTTPBadRequest, Response, json_response
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageNamesSchema, ProcessIdSchema, RepositoryIdSchema
from ahriman.web.schemas import AuthSchema, ErrorSchema, PackagePatchSchema, ProcessIdSchema, RepositoryIdSchema
from ahriman.web.views.base import BaseView
@ -53,7 +54,7 @@ class AddView(BaseView):
)
@aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.querystring_schema(RepositoryIdSchema)
@aiohttp_apispec.json_schema(PackageNamesSchema)
@aiohttp_apispec.json_schema(PackagePatchSchema)
async def post(self) -> Response:
"""
add new package
@ -65,13 +66,14 @@ class AddView(BaseView):
HTTPBadRequest: if bad data is supplied
"""
try:
data = await self.extract_data(["packages"])
data = await self.extract_data(["packages", "patches"])
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
patches = [PkgbuildPatch(patch["key"], patch.get("value", "")) for patch in data.get("patches", [])]
except Exception as ex:
raise HTTPBadRequest(reason=str(ex))
repository_id = self.repository_id()
username = await self.username()
process_id = self.spawner.packages_add(repository_id, packages, username, now=True)
process_id = self.spawner.packages_add(repository_id, packages, username, patches=patches, now=True)
return json_response({"process_id": process_id})

View File

@ -21,8 +21,9 @@ import aiohttp_apispec # type: ignore[import-untyped]
from aiohttp.web import HTTPBadRequest, Response, json_response
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageNamesSchema, ProcessIdSchema, RepositoryIdSchema
from ahriman.web.schemas import AuthSchema, ErrorSchema, PackagePatchSchema, ProcessIdSchema, RepositoryIdSchema
from ahriman.web.views.base import BaseView
@ -53,7 +54,7 @@ class RequestView(BaseView):
)
@aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.querystring_schema(RepositoryIdSchema)
@aiohttp_apispec.json_schema(PackageNamesSchema)
@aiohttp_apispec.json_schema(PackagePatchSchema)
async def post(self) -> Response:
"""
request to add new package
@ -65,13 +66,14 @@ class RequestView(BaseView):
HTTPBadRequest: if bad data is supplied
"""
try:
data = await self.extract_data(["packages"])
data = await self.extract_data(["packages", "patches"])
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
patches = [PkgbuildPatch(patch["key"], patch.get("value", "")) for patch in data.get("patches", [])]
except Exception as ex:
raise HTTPBadRequest(reason=str(ex))
username = await self.username()
repository_id = self.repository_id()
process_id = self.spawner.packages_add(repository_id, packages, username, now=False)
process_id = self.spawner.packages_add(repository_id, packages, username, patches=patches, now=False)
return json_response({"process_id": process_id})

View File

@ -25,7 +25,8 @@ from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.util import pretty_datetime
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, LogSchema, LogsSchema, PackageNameSchema, RepositoryIdSchema
from ahriman.web.schemas import AuthSchema, ErrorSchema, LogsSchema, PackageNameSchema, RepositoryIdSchema, \
VersionedLogSchema
from ahriman.web.views.base import BaseView
@ -128,7 +129,7 @@ class LogsView(BaseView):
)
@aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.match_info_schema(PackageNameSchema)
@aiohttp_apispec.json_schema(LogSchema)
@aiohttp_apispec.json_schema(VersionedLogSchema)
async def post(self) -> None:
"""
create new package log record

View File

@ -0,0 +1,103 @@
#
# Copyright (c) 2021-2023 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import aiohttp_apispec # type: ignore[import-untyped]
from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response
from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, PatchNameSchema, PatchSchema
from ahriman.web.views.base import BaseView
class PatchView(BaseView):
"""
package patch web view
Attributes:
DELETE_PERMISSION(UserAccess): (class attribute) delete permissions of self
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
"""
DELETE_PERMISSION = UserAccess.Full
GET_PERMISSION = UserAccess.Reporter
ROUTES = ["/api/v1/packages/{package}/patches/{patch}"]
@aiohttp_apispec.docs(
tags=["Packages"],
summary="Delete package patch",
description="Delete package patch by variable",
responses={
204: {"description": "Success response"},
401: {"description": "Authorization required", "schema": ErrorSchema},
403: {"description": "Access is forbidden", "schema": ErrorSchema},
500: {"description": "Internal server error", "schema": ErrorSchema},
},
security=[{"token": [DELETE_PERMISSION]}],
)
@aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.match_info_schema(PatchNameSchema)
async def delete(self) -> None:
"""
delete package patch
Raises:
HTTPNoContent: on success response
"""
package_base = self.request.match_info["package"]
variable = self.request.match_info["patch"]
self.service().patches_remove(package_base, variable)
raise HTTPNoContent
@aiohttp_apispec.docs(
tags=["Packages"],
summary="Get package patch",
description="Retrieve package patch by variable",
responses={
200: {"description": "Success response", "schema": PatchSchema},
401: {"description": "Authorization required", "schema": ErrorSchema},
403: {"description": "Access is forbidden", "schema": ErrorSchema},
404: {"description": "Patch name is unknown", "schema": ErrorSchema},
500: {"description": "Internal server error", "schema": ErrorSchema},
},
security=[{"token": [GET_PERMISSION]}],
)
@aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.match_info_schema(PatchNameSchema)
async def get(self) -> Response:
"""
get package patch
Returns:
Response: 200 with package patch on success
Raises:
HTTPNotFound: if package base is unknown
"""
package_base = self.request.match_info["package"]
variable = self.request.match_info["patch"]
patches = self.service().patches_get(package_base, variable)
selected = next((patch for patch in patches if patch.key == variable), None)
if selected is None:
raise HTTPNotFound
return json_response(selected.view())

View File

@ -0,0 +1,108 @@
#
# Copyright (c) 2021-2023 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import aiohttp_apispec # type: ignore[import-untyped]
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageNameSchema, PatchSchema
from ahriman.web.views.base import BaseView
class PatchesView(BaseView):
"""
package patches web view
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
GET_PERMISSION = UserAccess.Reporter
POST_PERMISSION = UserAccess.Full
ROUTES = ["/api/v1/packages/{package}/patches"]
@aiohttp_apispec.docs(
tags=["Packages"],
summary="Get package patches",
description="Retrieve all package patches",
responses={
200: {"description": "Success response", "schema": PatchSchema(many=True)},
401: {"description": "Authorization required", "schema": ErrorSchema},
403: {"description": "Access is forbidden", "schema": ErrorSchema},
500: {"description": "Internal server error", "schema": ErrorSchema},
},
security=[{"token": [GET_PERMISSION]}],
)
@aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.match_info_schema(PackageNameSchema)
async def get(self) -> Response:
"""
get package patches
Returns:
Response: 200 with package patches on success
Raises:
HTTPNotFound: if package base is unknown
"""
package_base = self.request.match_info["package"]
patches = self.service().patches_get(package_base, None)
response = [patch.view() for patch in patches]
return json_response(response)
@aiohttp_apispec.docs(
tags=["Packages"],
summary="Update package patch",
description="Update or create package patch",
responses={
204: {"description": "Success response"},
400: {"description": "Bad data is supplied", "schema": ErrorSchema},
401: {"description": "Authorization required", "schema": ErrorSchema},
403: {"description": "Access is forbidden", "schema": ErrorSchema},
500: {"description": "Internal server error", "schema": ErrorSchema},
},
security=[{"token": [GET_PERMISSION]}],
)
@aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.match_info_schema(PackageNameSchema)
@aiohttp_apispec.json_schema(PatchSchema)
async def post(self) -> None:
"""
update or create package patch
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: on success response
"""
package_base = self.request.match_info["package"]
data = await self.extract_data()
try:
key = data["key"]
value = data["value"]
except Exception as ex:
raise HTTPBadRequest(reason=str(ex))
self.service().patches_update(package_base, PkgbuildPatch(key, value))
raise HTTPNoContent

View File

@ -19,11 +19,10 @@
#
import aiohttp_apispec # type: ignore[import-untyped]
from aiohttp.web import HTTPNotFound, Response, json_response
from aiohttp.web import Response, json_response
from ahriman.core.exceptions import UnknownPackageError
from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, LogsSchemaV2, PackageNameSchema, PaginationSchema
from ahriman.web.schemas import AuthSchema, ErrorSchema, LogSchema, PackageNameSchema, PaginationSchema
from ahriman.web.views.base import BaseView
@ -43,7 +42,7 @@ class LogsView(BaseView):
summary="Get paginated package logs",
description="Retrieve package logs and the last package status",
responses={
200: {"description": "Success response", "schema": LogsSchemaV2},
200: {"description": "Success response", "schema": LogSchema(many=True)},
400: {"description": "Bad data is supplied", "schema": ErrorSchema},
401: {"description": "Authorization required", "schema": ErrorSchema},
403: {"description": "Access is forbidden", "schema": ErrorSchema},
@ -67,16 +66,12 @@ class LogsView(BaseView):
"""
package_base = self.request.match_info["package"]
limit, offset = self.page()
try:
_, status = self.service().package_get(package_base)
except UnknownPackageError:
raise HTTPNotFound(reason=f"Package {package_base} is unknown")
logs = self.service().logs_get(package_base, limit, offset)
response = {
"package_base": package_base,
"status": status.view(),
"logs": logs,
}
response = [
{
"created": created,
"message": message,
} for created, message in logs
]
return json_response(response)

View File

@ -9,6 +9,7 @@ from ahriman.core.repository import Repository
from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource
from ahriman.models.packagers import Packagers
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.result import Result
@ -22,7 +23,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
Returns:
argparse.Namespace: generated arguments for these test cases
"""
args.package = []
args.package = ["ahriman"]
args.exit_code = False
args.increment = True
args.now = False
@ -30,6 +31,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
args.source = PackageSource.Auto
args.dependencies = True
args.username = "username"
args.variable = None
return args
@ -51,6 +53,22 @@ def test_run(args: argparse.Namespace, configuration: Configuration, repository:
on_start_mock.assert_called_once_with()
def test_run_with_patches(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must run command and insert temporary patches
"""
args = _default_args(args)
args.variable = ["KEY=VALUE"]
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.application.application.Application.add")
application_mock = mocker.patch("ahriman.core.database.SQLite.patches_insert")
_, repository_id = configuration.check_loaded()
Add.run(args, repository_id, configuration, report=False)
application_mock.assert_called_once_with(args.package[0], [PkgbuildPatch("KEY", "VALUE")])
def test_run_with_updates(args: argparse.Namespace, configuration: Configuration, repository: Repository,
package_ahriman: Package, mocker: MockerFixture) -> None:
"""

View File

@ -180,7 +180,7 @@ def test_patch_set_create(application: Application, package_ahriman: Package, mo
"""
create_mock = mocker.patch("ahriman.core.database.SQLite.patches_insert")
Patch.patch_set_create(application, package_ahriman.base, PkgbuildPatch("version", package_ahriman.version))
create_mock.assert_called_once_with(package_ahriman.base, PkgbuildPatch("version", package_ahriman.version))
create_mock.assert_called_once_with(package_ahriman.base, [PkgbuildPatch("version", package_ahriman.version)])
def test_patch_set_remove(application: Application, package_ahriman: Package, mocker: MockerFixture) -> None:

View File

@ -254,6 +254,22 @@ def test_subparsers_package_add_option_refresh(parser: argparse.ArgumentParser)
assert args.refresh == 2
def test_subparsers_package_add_option_variable_empty(parser: argparse.ArgumentParser) -> None:
"""
package-add command must accept empty variable list as None
"""
args = parser.parse_args(["package-add", "ahriman"])
assert args.variable is None
def test_subparsers_package_add_option_variable_multiple(parser: argparse.ArgumentParser) -> None:
"""
repo-rebuild command must accept multiple depends-on
"""
args = parser.parse_args(["package-add", "ahriman", "-v", "var1", "-v", "var2"])
assert args.variable == ["var1", "var2"]
def test_subparsers_package_remove_option_architecture(parser: argparse.ArgumentParser) -> None:
"""
package-remove command must correctly parse architecture list

View File

@ -20,6 +20,7 @@ def test_init(task_ahriman: Task, database: SQLite, mocker: MockerFixture) -> No
"""
mocker.patch("ahriman.models.package.Package.from_build", return_value=task_ahriman.package)
load_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.load")
task_ahriman.init(Path("ahriman"), database, None)
load_mock.assert_called_once_with(Path("ahriman"), task_ahriman.package, [], task_ahriman.paths)

View File

@ -8,7 +8,7 @@ from ahriman.core.configuration import Configuration
from ahriman.core.database.migrations.m011_repository_name import migrate_data, migrate_package_repository, steps
def test_migration_check_depends() -> None:
def test_migration_repository_name() -> None:
"""
migration must not be empty
"""

View File

@ -7,9 +7,9 @@ def test_patches_get_insert(database: SQLite, package_ahriman: Package, package_
"""
must insert patch to database
"""
database.patches_insert(package_ahriman.base, PkgbuildPatch(None, "patch_1"))
database.patches_insert(package_ahriman.base, PkgbuildPatch("key", "patch_3"))
database.patches_insert(package_python_schedule.base, PkgbuildPatch(None, "patch_2"))
database.patches_insert(package_ahriman.base, [PkgbuildPatch(None, "patch_1")])
database.patches_insert(package_ahriman.base, [PkgbuildPatch("key", "patch_3")])
database.patches_insert(package_python_schedule.base, [PkgbuildPatch(None, "patch_2")])
assert database.patches_get(package_ahriman.base) == [
PkgbuildPatch(None, "patch_1"), PkgbuildPatch("key", "patch_3")
]
@ -19,9 +19,9 @@ def test_patches_list(database: SQLite, package_ahriman: Package, package_python
"""
must list all patches
"""
database.patches_insert(package_ahriman.base, PkgbuildPatch(None, "patch1"))
database.patches_insert(package_ahriman.base, PkgbuildPatch("key", "patch3"))
database.patches_insert(package_python_schedule.base, PkgbuildPatch(None, "patch2"))
database.patches_insert(package_ahriman.base, [PkgbuildPatch(None, "patch1")])
database.patches_insert(package_ahriman.base, [PkgbuildPatch("key", "patch3")])
database.patches_insert(package_python_schedule.base, [PkgbuildPatch(None, "patch2")])
assert database.patches_list(None, None) == {
package_ahriman.base: [PkgbuildPatch(None, "patch1"), PkgbuildPatch("key", "patch3")],
package_python_schedule.base: [PkgbuildPatch(None, "patch2")],
@ -32,8 +32,8 @@ def test_patches_list_filter(database: SQLite, package_ahriman: Package, package
"""
must list all patches filtered by package name (same as get)
"""
database.patches_insert(package_ahriman.base, PkgbuildPatch(None, "patch1"))
database.patches_insert(package_python_schedule.base, PkgbuildPatch(None, "patch2"))
database.patches_insert(package_ahriman.base, [PkgbuildPatch(None, "patch1")])
database.patches_insert(package_python_schedule.base, [PkgbuildPatch(None, "patch2")])
assert database.patches_list(package_ahriman.base, None) == {package_ahriman.base: [PkgbuildPatch(None, "patch1")]}
assert database.patches_list(package_python_schedule.base, None) == {
@ -46,9 +46,9 @@ def test_patches_list_filter_by_variable(database: SQLite, package_ahriman: Pack
"""
must list all patches filtered by package name (same as get)
"""
database.patches_insert(package_ahriman.base, PkgbuildPatch(None, "patch1"))
database.patches_insert(package_ahriman.base, PkgbuildPatch("key", "patch2"))
database.patches_insert(package_python_schedule.base, PkgbuildPatch(None, "patch3"))
database.patches_insert(package_ahriman.base, [PkgbuildPatch(None, "patch1")])
database.patches_insert(package_ahriman.base, [PkgbuildPatch("key", "patch2")])
database.patches_insert(package_python_schedule.base, [PkgbuildPatch(None, "patch3")])
assert database.patches_list(None, None) == {
package_ahriman.base: [PkgbuildPatch(None, "patch1"), PkgbuildPatch("key", "patch2")],
@ -63,8 +63,8 @@ def test_patches_insert_remove(database: SQLite, package_ahriman: Package, packa
"""
must remove patch from database
"""
database.patches_insert(package_ahriman.base, PkgbuildPatch(None, "patch1"))
database.patches_insert(package_python_schedule.base, PkgbuildPatch(None, "patch2"))
database.patches_insert(package_ahriman.base, [PkgbuildPatch(None, "patch1")])
database.patches_insert(package_python_schedule.base, [PkgbuildPatch(None, "patch2")])
database.patches_remove(package_ahriman.base, None)
assert database.patches_get(package_ahriman.base) == []
@ -76,9 +76,9 @@ def test_patches_insert_remove_by_variable(database: SQLite, package_ahriman: Pa
"""
must remove patch from database by variable
"""
database.patches_insert(package_ahriman.base, PkgbuildPatch(None, "patch1"))
database.patches_insert(package_ahriman.base, PkgbuildPatch("key", "patch3"))
database.patches_insert(package_python_schedule.base, PkgbuildPatch(None, "patch2"))
database.patches_insert(package_ahriman.base, [PkgbuildPatch(None, "patch1")])
database.patches_insert(package_ahriman.base, [PkgbuildPatch("key", "patch3")])
database.patches_insert(package_python_schedule.base, [PkgbuildPatch(None, "patch2")])
database.patches_remove(package_ahriman.base, ["key"])
assert database.patches_get(package_ahriman.base) == [PkgbuildPatch(None, "patch1")]
@ -89,8 +89,14 @@ def test_patches_insert_insert(database: SQLite, package_ahriman: Package) -> No
"""
must update patch in database
"""
database.patches_insert(package_ahriman.base, PkgbuildPatch(None, "patch1"))
database.patches_insert(package_ahriman.base, [PkgbuildPatch(None, "patch1")])
assert database.patches_get(package_ahriman.base) == [PkgbuildPatch(None, "patch1")]
database.patches_insert(package_ahriman.base, PkgbuildPatch(None, "patch2"))
database.patches_insert(package_ahriman.base, [PkgbuildPatch(None, "patch2")])
assert database.patches_get(package_ahriman.base) == [PkgbuildPatch(None, "patch2")]
database.patches_insert(package_ahriman.base, [PkgbuildPatch(None, "patch3"), PkgbuildPatch("key", "patch4")])
assert database.patches_get(package_ahriman.base) == [
PkgbuildPatch(None, "patch3"),
PkgbuildPatch("key", "patch4"),
]

View File

@ -1,12 +1,14 @@
import pytest
from pytest_mock import MockerFixture
from unittest.mock import call as MockCall
from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.status.watcher import Watcher
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
def test_load(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
@ -163,6 +165,42 @@ def test_package_update_unknown(watcher: Watcher, package_ahriman: Package) -> N
watcher.package_update(package_ahriman.base, BuildStatusEnum.Unknown, None)
def test_patches_get(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must return patches for the package
"""
patches_mock = mocker.patch("ahriman.core.database.SQLite.patches_list")
watcher.patches_get(package_ahriman.base, None)
watcher.patches_get(package_ahriman.base, "var")
patches_mock.assert_has_calls([
MockCall(package_ahriman.base, None),
MockCall().get(package_ahriman.base, []),
MockCall(package_ahriman.base, ["var"]),
MockCall().get(package_ahriman.base, []),
])
def test_patches_remove(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must remove patches for the package
"""
patches_mock = mocker.patch("ahriman.core.database.SQLite.patches_remove")
watcher.patches_remove(package_ahriman.base, "var")
patches_mock.assert_called_once_with(package_ahriman.base, ["var"])
def test_patches_update(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must update patches for the package
"""
patch = PkgbuildPatch("key", "value")
patches_mock = mocker.patch("ahriman.core.database.SQLite.patches_insert")
watcher.patches_update(package_ahriman.base, patch)
patches_mock.assert_called_once_with(package_ahriman.base, [patch])
def test_status_update(watcher: Watcher) -> None:
"""
must update service status

View File

@ -4,6 +4,7 @@ from pytest_mock import MockerFixture
from unittest.mock import MagicMock
from ahriman.core.spawn import Spawn
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.process_status import ProcessStatus
from ahriman.models.repository_id import RepositoryId
@ -56,11 +57,12 @@ def test_spawn_process(spawner: Spawn, repository_id: RepositoryId, mocker: Mock
"""
start_mock = mocker.patch("multiprocessing.Process.start")
assert spawner._spawn_process(repository_id, "add", "ahriman", now="", maybe="?", none=None)
assert spawner._spawn_process(repository_id, "command", "argument",
empty="", string="v", list=["a", "b"], empty_list=[], none=None)
start_mock.assert_called_once_with()
spawner.args_parser.parse_args.assert_called_once_with(
spawner.command_arguments + [
"add", "ahriman", "--now", "--maybe", "?"
"command", "argument", "--empty", "--string", "v", "--list", "a", "--list", "b",
]
)
@ -99,7 +101,7 @@ def test_packages_add(spawner: Spawn, repository_id: RepositoryId, mocker: Mocke
must call package addition
"""
spawn_mock = mocker.patch("ahriman.core.spawn.Spawn._spawn_process")
assert spawner.packages_add(repository_id, ["ahriman", "linux"], None, now=False)
assert spawner.packages_add(repository_id, ["ahriman", "linux"], None, patches=[], now=False)
spawn_mock.assert_called_once_with(repository_id, "package-add", "ahriman", "linux", username=None)
@ -108,7 +110,7 @@ def test_packages_add_with_build(spawner: Spawn, repository_id: RepositoryId, mo
must call package addition with update
"""
spawn_mock = mocker.patch("ahriman.core.spawn.Spawn._spawn_process")
assert spawner.packages_add(repository_id, ["ahriman", "linux"], None, now=True)
assert spawner.packages_add(repository_id, ["ahriman", "linux"], None, patches=[], now=True)
spawn_mock.assert_called_once_with(repository_id, "package-add", "ahriman", "linux", username=None, now="")
@ -117,10 +119,21 @@ def test_packages_add_with_username(spawner: Spawn, repository_id: RepositoryId,
must call package addition with username
"""
spawn_mock = mocker.patch("ahriman.core.spawn.Spawn._spawn_process")
assert spawner.packages_add(repository_id, ["ahriman", "linux"], "username", now=False)
assert spawner.packages_add(repository_id, ["ahriman", "linux"], "username", patches=[], now=False)
spawn_mock.assert_called_once_with(repository_id, "package-add", "ahriman", "linux", username="username")
def test_packages_add_with_patches(spawner: Spawn, repository_id: RepositoryId, mocker: MockerFixture) -> None:
"""
must call package addition with patches
"""
patches = [PkgbuildPatch("key", "value"), PkgbuildPatch("key", "value")]
spawn_mock = mocker.patch("ahriman.core.spawn.Spawn._spawn_process")
assert spawner.packages_add(repository_id, ["ahriman", "linux"], None, patches=patches, now=False)
spawn_mock.assert_called_once_with(repository_id, "package-add", "ahriman", "linux", username=None,
variable=[patch.serialize() for patch in patches])
def test_packages_rebuild(spawner: Spawn, repository_id: RepositoryId, mocker: MockerFixture) -> None:
"""
must call package rebuild

View File

@ -2,6 +2,7 @@ import datetime
import logging
import os
import pytest
import shlex
from pathlib import Path
from pytest_mock import MockerFixture
@ -11,7 +12,7 @@ from unittest.mock import call as MockCall
from ahriman.core.exceptions import BuildError, CalledProcessError, OptionError, UnsafeRunError
from ahriman.core.util import check_output, check_user, dataclass_view, enum_values, extract_user, filter_json, \
full_version, minmax, package_like, parse_version, partition, pretty_datetime, pretty_size, safe_filename, \
srcinfo_property, srcinfo_property_list, trim_package, utcnow, walk
srcinfo_property, srcinfo_property_list, trim_package, unquote, utcnow, walk
from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource
from ahriman.models.repository_paths import RepositoryPaths
@ -443,6 +444,26 @@ def test_trim_package() -> None:
assert trim_package("package: a description") == "package"
def test_unquote() -> None:
"""
must remove quotation marks
"""
for source in (
"abc",
"ab'c",
"ab\"c",
):
assert unquote(shlex.quote(source)) == source
def test_unquote_error() -> None:
"""
must raise value error on invalid quotation
"""
with pytest.raises(ValueError):
unquote("ab'c")
def test_utcnow() -> None:
"""
must generate correct timestamp

View File

@ -34,9 +34,18 @@ def test_quote() -> None:
"""
must quote strings if unsafe flag is not set
"""
assert PkgbuildPatch("key", "value").quote("value") == """value"""
assert PkgbuildPatch("key", "va'lue").quote("va'lue") == """'va'"'"'lue'"""
assert PkgbuildPatch("key", "va'lue", unsafe=True).quote("va'lue") == """va'lue"""
assert PkgbuildPatch.quote("value") == """value"""
assert PkgbuildPatch.quote("va'lue") == """'va'"'"'lue'"""
def test_from_env() -> None:
"""
must construct patch from environment variable
"""
assert PkgbuildPatch.from_env("KEY=VALUE") == PkgbuildPatch("KEY", "VALUE")
assert PkgbuildPatch.from_env("KEY=VA=LUE") == PkgbuildPatch("KEY", "VA=LUE")
assert PkgbuildPatch.from_env("KEY=") == PkgbuildPatch("KEY", "")
assert PkgbuildPatch.from_env("KEY") == PkgbuildPatch("KEY", "")
def test_serialize() -> None:
@ -46,7 +55,19 @@ def test_serialize() -> None:
assert PkgbuildPatch("key", "value").serialize() == "key=value"
assert PkgbuildPatch("key", "42").serialize() == "key=42"
assert PkgbuildPatch("key", "4'2").serialize() == """key='4'"'"'2'"""
assert PkgbuildPatch("key", "4'2", unsafe=True).serialize() == "key=4'2"
def test_from_env_serialize() -> None:
"""
must serialize and parse back
"""
for patch in (
PkgbuildPatch("key", "value"),
PkgbuildPatch("key", "4'2"),
PkgbuildPatch("arch", ["i686", "x86_64"]),
PkgbuildPatch("key", ["val'ue", "val\"ue2"]),
):
assert PkgbuildPatch.from_env(patch.serialize()) == patch
def test_serialize_plain_diff() -> None:
@ -60,7 +81,7 @@ def test_serialize_function() -> None:
"""
must correctly serialize function values
"""
assert PkgbuildPatch("key()", "{ value }", unsafe=True).serialize() == "key() { value }"
assert PkgbuildPatch("key()", "{ value }").serialize() == "key() { value }"
def test_serialize_list() -> None:
@ -69,7 +90,13 @@ def test_serialize_list() -> None:
"""
assert PkgbuildPatch("arch", ["i686", "x86_64"]).serialize() == """arch=(i686 x86_64)"""
assert PkgbuildPatch("key", ["val'ue", "val\"ue2"]).serialize() == """key=('val'"'"'ue' 'val"ue2')"""
assert PkgbuildPatch("key", ["val'ue", "val\"ue2"], unsafe=True).serialize() == """key=(val'ue val"ue2)"""
def test_view() -> None:
"""
must correctly serialize to json
"""
assert PkgbuildPatch("key", "value").view() == {"key": "key", "value": "value"}
def test_write(mocker: MockerFixture) -> None:

View File

@ -17,7 +17,7 @@ def test_id() -> None:
"""
must correctly generate id
"""
assert RepositoryId("", "").id == "-"
assert RepositoryId("", "").id == ""
assert RepositoryId("arch", "repo").id == "arch-repo"

View File

@ -0,0 +1 @@
# schema testing goes in view class tests

View File

@ -0,0 +1 @@
# schema testing goes in view class tests

View File

@ -0,0 +1 @@
# schema testing goes in view class tests

View File

@ -0,0 +1 @@
# schema testing goes in view class tests

View File

@ -4,6 +4,7 @@ from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
from unittest.mock import AsyncMock
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.repository_id import RepositoryId
from ahriman.models.user_access import UserAccess
from ahriman.web.views.v1.service.add import AddView
@ -40,13 +41,42 @@ async def test_post(client: TestClient, repository_id: RepositoryId, mocker: Moc
assert not request_schema.validate(payload)
response = await client.post("/api/v1/service/add", json=payload)
assert response.ok
add_mock.assert_called_once_with(repository_id, ["ahriman"], "username", now=True)
add_mock.assert_called_once_with(repository_id, ["ahriman"], "username", patches=[], now=True)
json = await response.json()
assert json["process_id"] == "abc"
assert not response_schema.validate(json)
async def test_post_patches(client: TestClient, repository_id: RepositoryId, mocker: MockerFixture) -> None:
"""
must call post request with patches correctly
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add", return_value="abc")
user_mock = AsyncMock()
user_mock.return_value = "username"
mocker.patch("ahriman.web.views.base.BaseView.username", side_effect=user_mock)
request_schema = pytest.helpers.schema_request(AddView.post)
payload = {
"packages": ["ahriman"],
"patches": [
{
"key": "k",
"value": "v",
},
{
"key": "k2",
},
]
}
assert not request_schema.validate(payload)
response = await client.post("/api/v1/service/add", json=payload)
assert response.ok
add_mock.assert_called_once_with(repository_id, ["ahriman"], "username",
patches=[PkgbuildPatch("k", "v"), PkgbuildPatch("k2", "")], now=True)
async def test_post_empty(client: TestClient, mocker: MockerFixture) -> None:
"""
must call raise 400 on empty request

View File

@ -4,6 +4,7 @@ from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
from unittest.mock import AsyncMock
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.repository_id import RepositoryId
from ahriman.models.user_access import UserAccess
from ahriman.web.views.v1.service.request import RequestView
@ -40,13 +41,42 @@ async def test_post(client: TestClient, repository_id: RepositoryId, mocker: Moc
assert not request_schema.validate(payload)
response = await client.post("/api/v1/service/request", json=payload)
assert response.ok
add_mock.assert_called_once_with(repository_id, ["ahriman"], "username", now=False)
add_mock.assert_called_once_with(repository_id, ["ahriman"], "username", patches=[], now=False)
json = await response.json()
assert json["process_id"] == "abc"
assert not response_schema.validate(json)
async def test_post_patches(client: TestClient, repository_id: RepositoryId, mocker: MockerFixture) -> None:
"""
must call post request with patches correctly
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add", return_value="abc")
user_mock = AsyncMock()
user_mock.return_value = "username"
mocker.patch("ahriman.web.views.base.BaseView.username", side_effect=user_mock)
request_schema = pytest.helpers.schema_request(RequestView.post)
payload = {
"packages": ["ahriman"],
"patches": [
{
"key": "k",
"value": "v",
},
{
"key": "k2",
},
]
}
assert not request_schema.validate(payload)
response = await client.post("/api/v1/service/request", json=payload)
assert response.ok
add_mock.assert_called_once_with(repository_id, ["ahriman"], "username",
patches=[PkgbuildPatch("k", "v"), PkgbuildPatch("k2", "")], now=False)
async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None:
"""
must raise exception on missing packages payload

View File

@ -0,0 +1,81 @@
import pytest
from aiohttp.test_utils import TestClient
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.user_access import UserAccess
from ahriman.web.views.v1.status.patch import PatchView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET",):
request = pytest.helpers.request("", "", method)
assert await PatchView.get_permission(request) == UserAccess.Reporter
for method in ("DELETE",):
request = pytest.helpers.request("", "", method)
assert await PatchView.get_permission(request) == UserAccess.Full
def test_routes() -> None:
"""
must return correct routes
"""
assert PatchView.ROUTES == ["/api/v1/packages/{package}/patches/{patch}"]
async def test_delete(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None:
"""
must delete patch for package
"""
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
await client.post(f"/api/v1/packages/{package_python_schedule.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_python_schedule.view()})
patch_key = "k"
await client.post(f"/api/v1/packages/{package_ahriman.base}/patches", json={"key": patch_key, "value": "v"})
await client.post(f"/api/v1/packages/{package_python_schedule.base}/patches",
json={"key": patch_key, "value": "v2"})
response = await client.delete(f"/api/v1/packages/{package_ahriman.base}/patches/{patch_key}")
assert response.status == 204
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/patches")
assert not await response.json()
response = await client.get(f"/api/v1/packages/{package_python_schedule.base}/patches")
assert await response.json()
async def test_get(client: TestClient, package_ahriman: Package) -> None:
"""
must get patch for package
"""
patch = PkgbuildPatch("k", "v")
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
await client.post(f"/api/v1/packages/{package_ahriman.base}/patches", json=patch.view())
response_schema = pytest.helpers.schema_response(PatchView.get)
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/patches/{patch.key}")
assert response.status == 200
patches = await response.json()
assert not response_schema.validate(patches)
assert patches == patch.view()
async def test_get_not_found(client: TestClient, package_ahriman: Package) -> None:
"""
must return not found for missing package
"""
response_schema = pytest.helpers.schema_response(PatchView.get, code=404)
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/patches/random")
assert response.status == 404
assert not response_schema.validate(await response.json())

View File

@ -0,0 +1,75 @@
import pytest
from aiohttp.test_utils import TestClient
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.user_access import UserAccess
from ahriman.web.views.v1.status.patches import PatchesView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET",):
request = pytest.helpers.request("", "", method)
assert await PatchesView.get_permission(request) == UserAccess.Reporter
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await PatchesView.get_permission(request) == UserAccess.Full
def test_routes() -> None:
"""
must return correct routes
"""
assert PatchesView.ROUTES == ["/api/v1/packages/{package}/patches"]
async def test_get(client: TestClient, package_ahriman: Package) -> None:
"""
must get patch for package
"""
patch = PkgbuildPatch("k", "v")
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
await client.post(f"/api/v1/packages/{package_ahriman.base}/patches", json=patch.view())
response_schema = pytest.helpers.schema_response(PatchesView.get)
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/patches")
assert response.status == 200
patches = await response.json()
assert not response_schema.validate(patches)
assert patches == [patch.view()]
async def test_post(client: TestClient, package_ahriman: Package) -> None:
"""
must create patch
"""
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
request_schema = pytest.helpers.schema_request(PatchesView.post)
payload = {"key": "k", "value": "v"}
assert not request_schema.validate(payload)
response = await client.post(f"/api/v1/packages/{package_ahriman.base}/patches", json=payload)
assert response.status == 204
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/patches")
patches = await response.json()
assert patches == [payload]
async def test_post_exception(client: TestClient, package_ahriman: Package) -> None:
"""
must raise exception on invalid payload
"""
response_schema = pytest.helpers.schema_response(PatchesView.post, code=400)
response = await client.post(f"/api/v1/packages/{package_ahriman.base}/patches", json={})
assert response.status == 400
assert not response_schema.validate(await response.json())

View File

@ -44,7 +44,16 @@ async def test_get(client: TestClient, package_ahriman: Package) -> None:
logs = await response.json()
assert not response_schema.validate(logs)
assert logs["logs"] == [[42.0, "message 1"], [43.0, "message 2"]]
assert logs == [
{
"created": 42.0,
"message": "message 1",
},
{
"created": 43.0,
"message": "message 2",
},
]
async def test_get_with_pagination(client: TestClient, package_ahriman: Package) -> None:
@ -67,18 +76,7 @@ async def test_get_with_pagination(client: TestClient, package_ahriman: Package)
logs = await response.json()
assert not response_schema.validate(logs)
assert logs["logs"] == [[43.0, "message 2"]]
async def test_get_not_found(client: TestClient, package_ahriman: Package) -> None:
"""
must return not found for missing package
"""
response_schema = pytest.helpers.schema_response(LogsView.get, code=404)
response = await client.get(f"/api/v2/packages/{package_ahriman.base}/logs")
assert response.status == 404
assert not response_schema.validate(await response.json())
assert logs == [{"created": 43.0, "message": "message 2"}]
async def test_get_bad_request(client: TestClient, package_ahriman: Package) -> None: