1
Fork 0

Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
26c962a79b
fix(deps): update rust crate cli-table to v0.4.9 2024-08-27 15:00:34 +00:00
10e2740053
gitea: Implement commit statuses
For now this is just a simple implementation only supporting `put`
steps. `in` is a noop, so `no_get: true` is recommended.
2024-08-27 16:52:07 +02:00
3cde8dde52
gitea: Implement package 2024-08-27 16:52:05 +02:00
43b974361e
fix(gitea): Fix version data
Concourse only accepts strings for values in the version data.
2024-08-27 16:52:04 +02:00
95e21cb4fe
feat(gitea): Add Gitea resource to fetch PRs 2024-08-27 16:52:02 +02:00
21 changed files with 2405 additions and 0 deletions

View file

@ -0,0 +1,3 @@
.git/
target/
README.adoc

1524
images/gitea/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

27
images/gitea/Cargo.toml Normal file
View file

@ -0,0 +1,27 @@
[package]
name = "gitea"
version = "0.1.2"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
clap = { version = "4.2.0", features = ["cargo", "color", "unicode", "std", "derive"] }
color-eyre = "0.6.2"
reqwest = { version = "0.11.16", default-features = false, features = ["blocking", "json", "rustls-tls-native-roots"] }
serde_json = "1.0.95"
serde = { version = "1.0.159", features = ["derive"] }
time = { version = "0.3.20", features = ["formatting", "parsing", "serde"] }
url = { version = "2.3.1", features = ["serde"] }
cli-table = "0.4.7"
globwalk = "0.8.1"
[profile.release]
strip = "debuginfo"
lto = true
[profile.dev.package.backtrace]
opt-level = 3
[patch.crates-io]
color-eyre = { git = "https://github.com/sclu1034/color-eyre.git", branch = "fork" }

40
images/gitea/Dockerfile Normal file
View file

@ -0,0 +1,40 @@
FROM rust:alpine AS builder
# Use a dummy project to ensure the crate index is up to date.
RUN set -e; \
cargo new --color always /tmp/dummy; \
cargo add --color always --manifest-path /tmp/dummy/Cargo.toml serde; \
rm -rf /tmp/dummy; \
cargo new --color always --bin /app
WORKDIR /app
RUN apk add --no-cache \
# openssl-dev \
# pkgconfig \
musl-dev
# Build dependencies with a dummy project to cache those regardless of changes
# in the actual source code
COPY ./Cargo.toml ./Cargo.lock /app/
RUN cargo build --color always --release --locked
COPY . .
RUN touch -a -m ./src/main.rs && cargo build --color always --release --locked
FROM alpine AS final
RUN apk add --no-cache ca-certificates
ENV RUST_BACKTRACE=1
ENV RUST_LOG=info
COPY --from=builder /app/target/release/gitea /bin/
# Put this last, as it's the only thing that's different between variants.
# This way, the previous layers can all be shared.
ARG VARIANT="missing build arg 'VARIANT'"
ARG VERSION="0.1.0"
LABEL version="$VERSION"
COPY ./shims/${VARIANT}/* /opt/resource/

23
images/gitea/README.adoc Normal file
View file

@ -0,0 +1,23 @@
= Gitea Concourse Resource
== Actions
[source,yaml]
----
- name: prs
type: gitea-pr
icon: git
source:
access_token: ((my_cred.token))
repo: some-repo
owner: some_user_or_org
hostname: https://example.com
----
=== `check`
Returns the list of currently active pull requests
=== `in`
Writes information about the currently active pull requests to `$1/prs.json`, which can be consumed by a `load_var` step.

View file

@ -0,0 +1,2 @@
#!/bin/sh
/bin/gitea package check "$@"

2
images/gitea/shims/package/in Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
/bin/gitea package in "$@"

2
images/gitea/shims/package/out Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
/bin/gitea package out "$@"

2
images/gitea/shims/pr/check Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
/bin/gitea pr check "$@"

2
images/gitea/shims/pr/in Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
/bin/gitea pr in "$@"

2
images/gitea/shims/pr/out Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
/bin/gitea pr out "$@"

View file

@ -0,0 +1,2 @@
#!/bin/sh
/bin/gitea status check "$@"

2
images/gitea/shims/status/in Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
/bin/gitea status in "$@"

2
images/gitea/shims/status/out Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
/bin/gitea status out "$@"

View file

@ -0,0 +1,286 @@
use std::fs;
use std::io::{self, Read, Write};
use std::path::Path;
use cli_table::format::Justify;
use cli_table::{Cell, Style, Table};
use color_eyre::eyre::{self, Context};
use color_eyre::{Help, Report, Result};
use globwalk::{DirEntry, GlobWalkerBuilder};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::json;
use time::format_description::well_known::Iso8601;
use url::Url;
use crate::types::{Package, PackageType};
use crate::util::make_client;
use crate::Action;
#[derive(Deserialize, Serialize, Debug)]
struct Source {
access_token: String,
owner: String,
url: Url,
r#type: PackageType,
name: String,
}
#[derive(Deserialize, Serialize, Debug)]
struct Version {
version: String,
}
#[derive(Deserialize, Serialize, Debug)]
struct Params {
version: String,
globs: Vec<String>,
#[serde(default)]
fail_fast: bool,
#[serde(default)]
r#override: bool,
}
#[derive(Deserialize, Serialize, Debug)]
struct Config {
/// Resource configuration.
/// Passed verbatim from the definition in the pipeline.
source: Source,
/// For 'check':
/// Previous version of this resource.
/// Will be empty for the very first request.
/// For 'in':
/// The version metadata to create this as.
version: Option<Version>,
params: Option<Params>,
}
fn action_check(conf: Config) -> Result<()> {
let client = make_client(&conf.source.access_token)?;
let url = conf
.source
.url
.join(&format!("api/v1/packages/{}", conf.source.owner))
.wrap_err("Invalid URL")?;
let mut pkgs: Vec<Package> = client
.get(url)
.query(&[
("type", conf.source.r#type.to_string()),
("q", conf.source.name),
])
.send()?
.json()?;
if pkgs.is_empty() {
return io::stdout().write_all(b"[]").map_err(From::from);
}
pkgs.sort_unstable_by_key(|pkg| pkg.created_at);
let table: Vec<_> = pkgs
.iter()
.map(|pkg| {
vec![
pkg.id.cell().justify(Justify::Center),
pkg.name.clone().cell(),
pkg.owner.login.clone().cell(),
pkg.version.clone().cell(),
pkg.created_at
.format(&Iso8601::DEFAULT)
.unwrap_or_default()
.cell(),
]
})
.collect();
let table = table
.table()
.title(vec![
"#".cell().bold(true),
"Name".cell().bold(true),
"Owner".cell().bold(true),
"Version".cell().bold(true),
"Created At".cell().bold(true),
])
.bold(true);
let _ = cli_table::print_stderr(table);
let newest = pkgs
.last()
.expect("List of packages should not be empty.")
.version
.clone();
let out = if let Some(prev) = conf.version {
if prev.version == newest {
vec![prev]
} else {
vec![prev, Version { version: newest }]
}
} else {
vec![Version { version: newest }]
};
serde_json::to_writer_pretty(io::stdout(), &out)
.wrap_err("Failed to write result to stdout")?;
Ok(())
}
fn action_out(conf: Config, dir: impl AsRef<Path>) -> Result<()> {
let dir = dir
.as_ref()
.canonicalize()
.wrap_err_with(|| format!("Invalid file path '{}'", dir.as_ref().display()))?;
let Some(params) = conf.params else {
eyre::bail!("Missing params");
};
if params.globs.is_empty() {
eyre::bail!("`params.globs` must not be empty");
}
let client = make_client(&conf.source.access_token)?;
let url = conf
.source
.url
.join(&format!(
// The trailing slash is required, to make sure this entire URL is used
// when `join`ing the file name later. Otherwise, the last component
// would be considered a "file" part itself, and replaced by a future `join`.
"api/packages/{}/{}/{}/{}/",
conf.source.owner, conf.source.r#type, conf.source.name, params.version
))
.wrap_err("Invalid URL")?;
let walker = GlobWalkerBuilder::from_patterns(dir, &params.globs)
.max_depth(3)
.file_type(globwalk::FileType::FILE)
.build()
.wrap_err("Failed to create glob walker")?;
let handle_glob = |entry| {
let entry: DirEntry = entry?;
let name = entry.file_name();
let path = entry.path();
let url = url.join(&name.to_string_lossy()).wrap_err("Invalid url")?;
if params.r#override {
client
.delete(url.clone())
.send()
.wrap_err_with(|| format!("Failed request 'DELETE {}'", &url))?;
}
let data = fs::read(path)
.wrap_err_with(|| format!("Failed to read package file '{}'", path.display()))?;
let res = client
.put(url.clone())
.body(data)
.send()
.wrap_err_with(|| format!("Failed request 'PUT {}'", &url))?;
match res.status() {
StatusCode::CREATED => {
eprintln!(
"Uploaded file '{}', version={}, package={}",
name.to_string_lossy(),
params.version,
conf.source.name
);
}
StatusCode::BAD_REQUEST => {
eyre::bail!(
"Package, version or file name are invalid. package={}, version={}, file={}",
conf.source.name,
params.version,
name.to_string_lossy(),
);
}
StatusCode::CONFLICT => {
eyre::bail!(
"File '{}' already exists. version={}, package={}",
name.to_string_lossy(),
params.version,
conf.source.name
);
}
StatusCode::INTERNAL_SERVER_ERROR => {
eyre::bail!(
"Internal server error: {} {}",
res.status(),
res.text().unwrap_or_default()
);
}
code => {
eyre::bail!("Unexpected status code {}\ntext = {:?}", code, res.text());
}
}
Ok::<_, Report>(())
};
let mut has_entry = false;
let mut errors = Vec::new();
for entry in walker {
has_entry = true;
if let Err(err) = handle_glob(entry) {
errors.push(err);
if params.fail_fast {
break;
}
}
}
if !errors.is_empty() {
let mut err = eyre::eyre!("Failed to upload package with {} errors", errors.len());
for e in errors {
err = err.report(e);
}
return Err(err);
}
if !has_entry {
eyre::bail!("Globs didn't produce any files");
}
let out = json!({
"version": {
"version": params.version
}
});
serde_json::to_writer_pretty(io::stdout(), &out)
.wrap_err("Failed to write result to stdout")?;
Ok(())
}
pub(crate) fn run(action: &Action) -> Result<()> {
let config: Config = {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.wrap_err("Failed to read from stdin")?;
if buf.is_empty() {
eyre::bail!("No data received on stdin");
}
serde_json::from_str(&buf).wrap_err("Failed to parse stdin")?
};
match action {
Action::Check => action_check(config),
Action::In { dest: _ } => Ok(()),
Action::Out { src } => action_out(config, src),
}
}

171
images/gitea/src/cmd/pr.rs Normal file
View file

@ -0,0 +1,171 @@
use std::fs;
use std::io::{self, Read};
use std::path::Path;
use cli_table::format::Justify;
use cli_table::{print_stderr, Cell, Style, Table};
use color_eyre::eyre::{self, Context};
use color_eyre::Result;
use reqwest::blocking::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use time::OffsetDateTime;
use url::Url;
use crate::types::PullRequest;
use crate::util::make_client;
use crate::Action;
#[derive(Deserialize, Serialize, Debug)]
struct Source {
access_token: String,
owner: String,
repo: String,
url: Url,
}
#[derive(Deserialize, Serialize, Debug)]
struct Version {
prs: String,
#[serde(with = "time::serde::iso8601")]
timestamp: OffsetDateTime,
}
impl<'a, I: Iterator<Item = &'a PullRequest>> From<I> for Version {
fn from(prs: I) -> Self {
Self {
prs: prs.fold(String::new(), |mut s, pr| {
if !s.is_empty() {
s.push(',');
}
s.push_str(&pr.number.to_string());
s
}),
timestamp: OffsetDateTime::now_utc(),
}
}
}
#[derive(Deserialize, Serialize, Debug)]
struct Config {
/// Resource configuration.
/// Passed verbatim from the definition in the pipeline.
source: Source,
/// For 'check':
/// Previous version of this resource.
/// Will be empty for the very first request.
/// For 'in':
/// The version metadata to create this as.
version: Option<Version>,
}
fn fetch(src: &Source) -> Result<Response> {
let client = make_client(&src.access_token).wrap_err("Failed to create HTTP client")?;
let url = src
.url
.join(&format!("api/v1/repos/{}/{}/pulls", src.owner, src.repo))
.wrap_err("Invalid URL")?;
client
.get(url)
.query(&[("sort", "oldest"), ("state", "open")])
.send()
.map_err(From::from)
}
fn action_check(conf: Config) -> Result<()> {
let prs: Vec<PullRequest> = fetch(&conf.source)?.json()?;
let version = Version::from(prs.iter());
let out = if let Some(prev) = conf.version {
if prev.prs == version.prs {
vec![prev]
} else {
vec![prev, version]
}
} else {
vec![version]
};
let table: Vec<_> = prs
.iter()
.map(|pr| {
vec![
pr.number.cell().justify(Justify::Center),
pr.title.clone().cell(),
pr.user.login.clone().cell(),
pr.base.r#ref.clone().cell(),
pr.head.r#ref.clone().cell(),
]
})
.collect();
let table = table
.table()
.title(vec![
"#".cell().bold(true),
"Title".cell().bold(true),
"User".cell().bold(true),
"Base".cell().bold(true),
"Head".cell().bold(true),
])
.bold(true);
let _ = print_stderr(table);
serde_json::to_writer_pretty(io::stdout(), &out)
.wrap_err("Failed to write result to stdout")?;
Ok(())
}
fn action_in(conf: Config, dest: impl AsRef<Path>) -> Result<()> {
let old_version = if let Some(version) = conf.version {
version
} else {
eyre::bail!("Version missing in 'in' action.");
};
let bytes = fetch(&conf.source)?.bytes()?;
let prs: Vec<PullRequest> = serde_json::from_slice(&bytes)?;
let version = Version::from(prs.iter());
{
if version.prs != old_version.prs {
eyre::bail!("Version to fetch does not match current resource.");
}
}
let path = dest.as_ref().join("prs.json");
let _ = fs::create_dir_all(dest);
fs::write(&path, &bytes)?;
let out = json!({
"version": version,
});
serde_json::to_writer_pretty(io::stdout(), &out)?;
Ok(())
}
pub(crate) fn run(action: &Action) -> Result<()> {
let config: Config = {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.wrap_err("Failed to read from stdin")?;
if buf.is_empty() {
eyre::bail!("No data received on stdin");
}
serde_json::from_str(&buf).wrap_err("Failed to parse stdin")?
};
match action {
Action::Check => action_check(config),
Action::In { dest } => action_in(config, dest),
Action::Out { src: _ } => Ok(()),
}
}

View file

@ -0,0 +1,154 @@
use color_eyre::eyre;
use color_eyre::eyre::WrapErr;
use color_eyre::Help;
use color_eyre::Result;
use color_eyre::SectionExt;
use reqwest::StatusCode;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use std::io;
use std::io::Read;
use std::path::Path;
use url::Url;
use crate::util::make_client;
use crate::Action;
#[derive(Deserialize, Serialize, Debug)]
struct Source {
access_token: String,
owner: String,
url: Url,
repo: String,
sha: String,
context: String,
description: Option<String>,
target_url: Option<String>,
}
#[derive(Deserialize, Serialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
enum State {
Pending,
Success,
Error,
Failure,
Warning,
}
impl std::fmt::Display for State {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
State::Pending => write!(f, "pending"),
State::Success => write!(f, "success"),
State::Error => write!(f, "error"),
State::Failure => write!(f, "failure"),
State::Warning => write!(f, "warning"),
}
}
}
#[derive(Deserialize, Serialize, Debug)]
struct Params {
state: State,
description: Option<String>,
}
#[derive(Deserialize, Serialize, Debug)]
struct Config {
/// Resource configuration.
/// Passed verbatim from the definition in the pipeline.
source: Source,
params: Params,
}
fn action_out(conf: Config, _dir: impl AsRef<Path>) -> Result<()> {
let params = conf.params;
let description = params.description.or(conf.source.description);
let client = make_client(&conf.source.access_token)?;
let url = conf
.source
.url
.join(&format!(
// The trailing slash is required, to make sure this entire URL is used
// when `join`ing the file name later. Otherwise, the last component
// would be considered a "file" part itself, and replaced by a future `join`.
"api/v1/repos/{}/{}/statuses/{}",
conf.source.owner, conf.source.repo, conf.source.sha
))
.wrap_err("Invalid URL")?;
let body = json!({
"context": conf.source.context,
"description": description,
"state": params.state,
"target_url": conf.source.target_url,
});
let res = client
.post(url.clone())
.json(&body)
.send()
.wrap_err_with(|| format!("Failed to send request 'POST {}'", url))?;
match res.status() {
StatusCode::CREATED => {
eprintln!(
"Created status '{}' on commit '{}'",
params.state, conf.source.sha
);
}
StatusCode::BAD_REQUEST => {
eyre::bail!(
"Invalid request: {:?}. state={}, context={}, description={:?}, target_url={:?}",
res.text(),
params.state,
conf.source.context,
description,
conf.source.target_url
)
}
code => {
eyre::bail!("Unexpected status code {}\ntext = {:?}", code, res.text());
}
}
println!("{{ \"version\": {{}} }}");
Ok(())
}
pub(crate) fn run(action: &Action) -> Result<()> {
// TODO: Gitea does actually support fetching statuses, making `check` and `in` viable,
// theoretically. But it also doesn't make much sense to implement them.
match action {
Action::Check => {
// Dummy implemented that always reports nothing.
println!("[]");
Ok(())
}
Action::In { dest: _ } => {
println!("{{}}");
Ok(())
}
Action::Out { src } => {
let config: Config = {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.wrap_err("Failed to read from stdin")?;
if buf.is_empty() {
eyre::bail!("No data received on stdin");
}
serde_json::from_str(&buf)
.wrap_err("Failed to parse stdin")
.with_section(|| buf.header("JSON"))?
};
action_out(config, src)
}
}
}

56
images/gitea/src/main.rs Normal file
View file

@ -0,0 +1,56 @@
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use color_eyre::Result;
static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
#[derive(Debug, Parser)]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Clone, Debug, Subcommand)]
enum Commands {
Pr {
#[command(subcommand)]
action: Action,
},
Package {
#[command(subcommand)]
action: Action,
},
Status {
#[command(subcommand)]
action: Action,
},
}
#[derive(Clone, Debug, Subcommand)]
pub(crate) enum Action {
Check,
In { dest: PathBuf },
Out { src: PathBuf },
}
mod types;
mod util;
mod cmd {
pub mod package;
pub mod pr;
pub mod status;
}
fn main() -> Result<()> {
color_eyre::install()?;
let cli = Cli::parse();
match &cli.command {
Commands::Pr { action } => cmd::pr::run(action),
Commands::Package { action } => cmd::package::run(action),
Commands::Status { action } => cmd::status::run(action),
}
}

46
images/gitea/src/types.rs Normal file
View file

@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
#[derive(Clone, Debug, Deserialize)]
pub struct Ref {
pub r#ref: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct User {
pub login: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct PullRequest {
pub number: u64,
pub title: String,
pub user: User,
pub base: Ref,
pub head: Ref,
}
#[derive(Copy, Clone, Deserialize, Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum PackageType {
Generic,
}
impl std::fmt::Display for PackageType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PackageType::Generic => write!(f, "generic"),
}
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct Package {
#[serde(with = "time::serde::iso8601")]
pub created_at: OffsetDateTime,
pub id: u64,
pub name: String,
pub owner: User,
pub r#type: PackageType,
pub version: String,
}

19
images/gitea/src/util.rs Normal file
View file

@ -0,0 +1,19 @@
use color_eyre::Result;
use reqwest::blocking::Client;
use reqwest::header::HeaderMap;
use crate::USER_AGENT;
pub(crate) fn make_client(access_token: impl AsRef<str>) -> Result<Client> {
let mut headers = HeaderMap::new();
headers.insert(
"Authorization",
format!("token {}", access_token.as_ref()).try_into()?,
);
Client::builder()
.default_headers(headers)
.user_agent(USER_AGENT)
.build()
.map_err(From::from)
}

38
images/gitea/test.sh Executable file
View file

@ -0,0 +1,38 @@
#!/bin/sh
set -e
cargo build
run() {
./target/debug/gitea package "$1" <<EOF
{
$2
"source": {
"access_token": "$GITEA_ACCESS_TOKEN",
"owner": "concourse",
"url": "https://git.sclu1034.dev",
"type": "generic",
"name": "test"
}
}
EOF
}
if [ -n "$1" ]; then
run "$@"
echo ""
exit 0
fi
echo "$(tput bold)Test: Check existing versions$(tput sgr0)"
run check
printf "\n\n"
echo "$(tput bold)Test: Check 'v0.1.0'$(tput sgr0)"
run check '
"params": {
"version": "0.1.0"
},
'
printf "\n"