mirror of
https://git.sr.ht/~magic_rb/cluster
synced 2024-11-22 08:04:20 +01:00
Gitea Rust Rework
This commit is contained in:
parent
af83dbf7c4
commit
7b3ab825e3
80
docker/gitea/app.ini
Normal file
80
docker/gitea/app.ini
Normal file
|
@ -0,0 +1,80 @@
|
|||
APP_NAME = Red Alder Gitea
|
||||
RUN_MODE = prod
|
||||
RUN_USER = gitea
|
||||
|
||||
[repository]
|
||||
ROOT = /data/gitea/git/repositories
|
||||
|
||||
[repository.local]
|
||||
LOCAL_COPY_PATH = /data/gitea/tmp/local-repo
|
||||
|
||||
[repository.upload]
|
||||
TEMP_PATH = /data/gitea/uploads
|
||||
|
||||
[server]
|
||||
APP_DATA_PATH = /data/gitea
|
||||
SSH_DOMAIN = localhost
|
||||
HTTP_PORT = 3000
|
||||
ROOT_URL = https://gitea.redalder.org/
|
||||
DISABLE_SSH = false
|
||||
SSH_PORT = 22
|
||||
SSH_LISTEN_PORT = 22
|
||||
LFS_START_SERVER = true
|
||||
LFS_CONTENT_PATH = /data/gitea/git/lfs
|
||||
DOMAIN = gitea.redalder.org
|
||||
OFFLINE_MODE = false
|
||||
|
||||
[database]
|
||||
PATH = /data/gitea/gitea.db
|
||||
DB_TYPE = postgres
|
||||
HOST = localhost
|
||||
POST = 5764
|
||||
NAME = gitea
|
||||
USER = gitea
|
||||
PASSWD = gitea
|
||||
SCHEMA =
|
||||
SSL_MODE = disable
|
||||
CHARSET = utf8
|
||||
|
||||
[indexer]
|
||||
ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve
|
||||
|
||||
[session]
|
||||
PROVIDER_CONFIG = /data/gitea/sessions
|
||||
PROVIDER = file
|
||||
|
||||
[picture]
|
||||
AVATAR_UPLOAD_PATH = /data/gitea/avatars
|
||||
REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars
|
||||
DISABLE_GRAVATAR = false
|
||||
ENABLE_FEDERATED_AVATAR = true
|
||||
|
||||
[attachment]
|
||||
PATH = /data/gitea/attachments
|
||||
|
||||
[log]
|
||||
ROOT_PATH = /data/gitea/log
|
||||
MODE = file
|
||||
LEVEL = info
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = false
|
||||
REQUIRE_SIGNIN_VIEW = false
|
||||
REGISTER_EMAIL_CONFIRM = false
|
||||
ENABLE_NOTIFY_MAIL = false
|
||||
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
|
||||
ENABLE_CAPTCHA = false
|
||||
DEFAULT_KEEP_EMAIL_PRIVATE = false
|
||||
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
|
||||
DEFAULT_ENABLE_TIMETRACKING = true
|
||||
NO_REPLY_ADDRESS = noreply.localhost
|
||||
|
||||
[mailer]
|
||||
ENABLED = false
|
||||
|
||||
[openid]
|
||||
ENABLE_OPENID_SIGNIN = true
|
||||
ENABLE_OPENID_SIGNUP = true
|
4
docker/gitea/config.toml
Normal file
4
docker/gitea/config.toml
Normal file
|
@ -0,0 +1,4 @@
|
|||
gid = 5000
|
||||
uid = 5000
|
||||
app_ini = "/app.ini"
|
||||
app_ini_overwrite = true
|
|
@ -1,7 +1,7 @@
|
|||
{ pkgs, system, nixpkgs, ... }:
|
||||
let
|
||||
# pkgs = (import nixpkgs { inherit system; }).pkgsMusl;
|
||||
env = let
|
||||
contents = let
|
||||
defaults = {
|
||||
userUid = builtins.toString 5001;
|
||||
userGid = builtins.toString 5001;
|
||||
|
@ -29,29 +29,16 @@ let
|
|||
};
|
||||
bashLib = ../bash-lib;
|
||||
in
|
||||
with pkgs; with defaults; pkgs.writeShellScriptBin "conf" ''
|
||||
_conf_user_uid=${userUid}
|
||||
_conf_user_gid=${userGid}
|
||||
_conf_user=${user}
|
||||
_conf_data=${data}
|
||||
|
||||
_prog_busybox=${busybox}
|
||||
_prog_gitea=${custom.gitea}
|
||||
_prog_bash=${bash}
|
||||
_prog_bash_lib=${bashLib}
|
||||
'';
|
||||
csiMount = pkgs.runCommandNoCC
|
||||
"csiMount" {}
|
||||
''${pkgs.busybox}/bin/mkdir -p $out/data/gitea'';
|
||||
init = pkgs.writeShellScript "init" (builtins.readFile ./init);
|
||||
pkgs.symlinkJoin { name = "contents"; paths = [ custom.gitea ]; };
|
||||
init = "${pkgs.rust-runner}/bin/gitea";
|
||||
in
|
||||
pkgs.dockerTools.buildLayeredImage {
|
||||
name = "gitea";
|
||||
tag = "latest";
|
||||
|
||||
contents = csiMount;
|
||||
inherit contents;
|
||||
|
||||
config = {
|
||||
Entrypoint = [ "${init}" "${env}/bin/conf"];
|
||||
Entrypoint = [ "${init}" ];
|
||||
};
|
||||
}
|
||||
|
|
13
docker/gitea/docker-compose.yaml
Normal file
13
docker/gitea/docker-compose.yaml
Normal file
|
@ -0,0 +1,13 @@
|
|||
version: "3.3" # optional since v1.27.0
|
||||
services:
|
||||
gitea:
|
||||
image: gitea:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./config.toml
|
||||
target: /config.toml
|
||||
- type: bind
|
||||
source: ./config.toml
|
||||
target: /app.ini
|
|
@ -1,90 +0,0 @@
|
|||
# -*- mode: shell-script; -*-
|
||||
|
||||
conf=$1
|
||||
source $conf
|
||||
|
||||
source $_prog_bash_lib/main.bash
|
||||
|
||||
if [[ $($_prog_busybox/bin/id -u) = 0 ]] ; then
|
||||
$_prog_busybox/bin/cat << EOF
|
||||
### Gitea Nix Image Manual
|
||||
##
|
||||
## USER_UID ? $_conf_user_uid - default user id
|
||||
## USER_GID ? $_conf_user_gid - default group id
|
||||
## [ APP_INI ] - app.ini file location for Gitea
|
||||
## APP_INI_OVERWRITE - whether an existing app.ini shall be overwritten, anything else than "" means \`true\`!
|
||||
## for other options please look at https://docs.gitea.io/en-us/install-with-docker/#environment-variables
|
||||
|
||||
### Recommended volumes (many directories which exist in normal Docker containers, do not exist in this one)
|
||||
##
|
||||
## - $_conf_data - Gitea base data folder
|
||||
|
||||
EOF
|
||||
|
||||
default_opt USER_UID "$_conf_user_uid"
|
||||
default_opt USER_GID "$_conf_user_gid"
|
||||
default_opt APP_INI ""
|
||||
_app_ini_overwrite=$([[ ! -z "${APP_INI_OVERWRITE:-}" ]] && printf true || printf false)
|
||||
|
||||
$_prog_busybox/bin/cat << EOF
|
||||
### Starting with options:
|
||||
## USER_UID = "$_user_uid"
|
||||
## USER_GID = "$_user_gid"
|
||||
## APP_INI = "$_app_ini"
|
||||
## APP_INI_OVERWRITE = "$_app_ini_overwrite"
|
||||
EOF
|
||||
$_prog_busybox/bin/env
|
||||
|
||||
(
|
||||
set -e
|
||||
echo "gitea:x:$_user_uid:$_user_gid:Gitea:$_conf_data:$_prog_bash/bin/bash" > /etc/passwd
|
||||
echo "gitea:x:$_user_gid:" > /etc/group
|
||||
) || echo_exit "Failed to create user and group!"
|
||||
|
||||
mkdir_chown $_conf_data "$_user_uid" "$_user_gid"
|
||||
mkdir_chown /tmp "$_user_uid" "$_user_gid"
|
||||
|
||||
check_owner "$_conf_data" "$_user_uid" "$_user_gid"
|
||||
|
||||
save_env "_user_uid \
|
||||
_user_gid \
|
||||
_conf_data \
|
||||
_prog_gitea \
|
||||
_app_ini \
|
||||
_app_ini_overwrite \
|
||||
" > /env # TODO: exited even though it must have succeded || \
|
||||
# echo_exit "Failed to save environment!"
|
||||
|
||||
check_root "$_user_uid"
|
||||
exec $_prog_busybox/bin/su gitea -c "$0 $@" || \
|
||||
echo_exit "Failed to switch user!"
|
||||
else
|
||||
source /env || \
|
||||
echo_exit "Failed to source env!"
|
||||
|
||||
if [[ ! -z "$_app_ini" ]] ; then
|
||||
if [[ -f "$_app_ini" ]] ; then
|
||||
if [[ ! -f "$_conf_data/app.ini" ]] ; then
|
||||
$_prog_busybox/bin/cp "$_app_ini" "$_conf_data/app.ini" || \
|
||||
echo_exit "Failed to copy app.ini!"
|
||||
else
|
||||
if [[ "$_app_ini_overwrite" = "true" ]] ; then
|
||||
$_prog_busybox/bin/cp "$_app_ini" "$_conf_data/app.ini" || \
|
||||
echo_exit "Failed to copy app.ini!"
|
||||
elif [[ "$_app_ini_overwrite" = "false" ]] ; then
|
||||
echo_exit "APP_INI set, but $_conf_data/app.ini exists!"
|
||||
else
|
||||
echo_exit "\$_api_ini_overwrite has invalid value $_api_init_overwrite, this is an internal issue, please report ASAP!"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo_exit "APP_INI set, but $_app_ini does not exist!"
|
||||
fi
|
||||
fi
|
||||
|
||||
export GITEA_WORK_DIR=$_conf_data
|
||||
|
||||
echo
|
||||
echo "Starting Gitea!"
|
||||
exec $_prog_gitea/bin/gitea -c $_conf_data/app.ini $@
|
||||
fi
|
23
flake.nix
23
flake.nix
|
@ -20,26 +20,37 @@
|
|||
|
||||
outputs = { self, nixpkgs, ... }@inputs:
|
||||
let
|
||||
lib =
|
||||
rlib =
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
import ./lib.nix { inherit system nixpkgs pkgs inputs; };
|
||||
flakes = lib.flakes ./nix-packages [
|
||||
flakes = rlib.flakes ./nix-packages [
|
||||
"klippy"
|
||||
"mainsail"
|
||||
"moonraker"
|
||||
"rust-runner"
|
||||
];
|
||||
dockerImages = lib.dockerImages ./docker [
|
||||
pkgs = rlib.pkgsWithFlakes flakes;
|
||||
|
||||
dockerImages = rlib.dockerImages pkgs ./docker [
|
||||
"klippy-moonraker"
|
||||
"postgresql"
|
||||
"gitea"
|
||||
"csi-driver-nfs"
|
||||
];
|
||||
pkgs = lib.pkgsWithFlakes flakes;
|
||||
moduleArg = ({ inherit pkgs; } // inputs);
|
||||
containerTest = let
|
||||
all-modules = import <nixpkgs/nixos/modules/module-list.nix>;
|
||||
custom-module = rec {
|
||||
services.mysql.enable = true;
|
||||
};
|
||||
pkgs = import nixpkgs { system = "x86_64-linux"; };
|
||||
in
|
||||
pkgs.writeText "test" (pkgs.lib.evalModules {
|
||||
modules = all-modules ++ [ custom-module ];
|
||||
}).config.services.mysql.dataDir;
|
||||
in {
|
||||
inherit flakes dockerImages;
|
||||
inherit flakes dockerImages containerTest;
|
||||
};
|
||||
}
|
||||
|
|
6
lib.nix
6
lib.nix
|
@ -6,17 +6,17 @@ with pkgs.lib; {
|
|||
in
|
||||
self
|
||||
);
|
||||
dockerImages = path: modules: genAttrs modules (module:
|
||||
dockerImages = pkgs: path: modules: genAttrs modules (module:
|
||||
import (path + "/${module}") ({ inherit pkgs system; } // inputs)
|
||||
);
|
||||
pkgsWithFlakes = flakes: import nixpkgs
|
||||
{
|
||||
inherit system;
|
||||
overlays = builtins.concatLists (forEach flakes (flake:
|
||||
overlays = builtins.concatLists (mapAttrsToList (_: flake:
|
||||
if builtins.hasAttr "overlay" flake then
|
||||
[ flake.overlay ]
|
||||
else
|
||||
[]
|
||||
));
|
||||
) flakes);
|
||||
};
|
||||
}
|
||||
|
|
2
nix-packages/rust-runner/.cargo/config
Normal file
2
nix-packages/rust-runner/.cargo/config
Normal file
|
@ -0,0 +1,2 @@
|
|||
[build]
|
||||
target = "x86_64-unknown-linux-musl"
|
14
nix-packages/rust-runner/.gitignore
vendored
Normal file
14
nix-packages/rust-runner/.gitignore
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/rust
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=rust
|
||||
|
||||
### Rust ###
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/rust
|
17
nix-packages/rust-runner/Cargo.toml
Normal file
17
nix-packages/rust-runner/Cargo.toml
Normal file
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "rust-runner"
|
||||
version = "0.1.0"
|
||||
authors = ["Magic_RB <magic_rb@redalder.org>"]
|
||||
edition = "2018"
|
||||
|
||||
[[bin]]
|
||||
name = "gitea"
|
||||
path = "src/gitea.rs"
|
||||
|
||||
[dependencies]
|
||||
toml = "0.5.8"
|
||||
serde = { version = "1.0.122", features = ["derive"] }
|
||||
nix = "0.19.1"
|
||||
simplelog = "0.9.0"
|
||||
log = "0.4.13"
|
||||
privdrop = "0.5.0"
|
32
nix-packages/rust-runner/flake.nix
Normal file
32
nix-packages/rust-runner/flake.nix
Normal file
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, ... }@inputs:
|
||||
let
|
||||
supportedSystems = [ "x86_64-linux" "i686-linux" "aarch64-linux" ];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
|
||||
in
|
||||
{
|
||||
overlay = final: prev:
|
||||
with final; {
|
||||
rust-runner = rustPlatform.buildRustPackage rec {
|
||||
pname = "rust-runner";
|
||||
version = "0.1";
|
||||
|
||||
src = ./.;
|
||||
|
||||
buildType = "debug";
|
||||
|
||||
cargoSha256 = "Z/Q66St/Q/suG8BxJXNwevdPvTQJgGzmvgGeCzP01KY=";
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
defaultPackage = forAllSystems (system: (import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ self.overlay ];
|
||||
}).rust-runner);
|
||||
};
|
||||
}
|
187
nix-packages/rust-runner/src/gitea.rs
Normal file
187
nix-packages/rust-runner/src/gitea.rs
Normal file
|
@ -0,0 +1,187 @@
|
|||
extern crate log;
|
||||
extern crate nix;
|
||||
extern crate serde;
|
||||
extern crate simplelog;
|
||||
extern crate toml;
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
fs::File,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use log::{error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn mkdir_chown<S: AsRef<str>>(s: S, uid: u32, gid: u32) -> Result<(), String> {
|
||||
use nix::{
|
||||
dir::Dir,
|
||||
fcntl::OFlag,
|
||||
sys::stat::{stat, Mode},
|
||||
unistd::{chown, mkdir},
|
||||
unistd::{Gid, Uid},
|
||||
};
|
||||
use std::fs::create_dir_all;
|
||||
|
||||
let s = s.as_ref();
|
||||
|
||||
if let Ok(_) = Dir::open(s, OFlag::empty(), Mode::empty()) {
|
||||
let stat = stat(s).map_err(|err| format!("Failed to stat: {} {}", s, err))?;
|
||||
|
||||
if stat.st_uid != uid || stat.st_gid != gid {
|
||||
warn!(
|
||||
"Directory {} exists, but has incorrect o/g: {}:{}, trying to chown...",
|
||||
s, uid, gid
|
||||
);
|
||||
chown(s, Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid)))
|
||||
.map_err(|err| format!("Failed to chown: {}, {}", s, err))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
} else {
|
||||
create_dir_all(s).map_err(|err| format!("Failed to mkdir: {} {}", s, err))?;
|
||||
chown(s, Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid)))
|
||||
.map_err(|err| format!("Failed to chown: {} {}", s, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn drop_privileges<S: AsRef<str>>(user: S) -> Result<(), String> {
|
||||
use privdrop::PrivDrop;
|
||||
|
||||
let user = user.as_ref();
|
||||
|
||||
PrivDrop::default()
|
||||
.user(user)
|
||||
.group(user)
|
||||
.apply()
|
||||
.map_err(|err| format!("Failed to drop priviledges to {}:{} {}", user, user, err))
|
||||
}
|
||||
|
||||
fn adduser<S: AsRef<str>>(name: S, uid: u32, gid: u32, home: S, shell: S) -> Result<(), String> {
|
||||
use std::{fs::OpenOptions, io::Write};
|
||||
|
||||
let passwd = "/etc/passwd";
|
||||
let group = "/etc/group";
|
||||
let mut file = OpenOptions::new()
|
||||
.read(false)
|
||||
.write(true)
|
||||
.append(true)
|
||||
.create(true)
|
||||
.open(passwd)
|
||||
.map_err(|_| format!("Cannot open: {}", passwd))?;
|
||||
|
||||
file.write_all(
|
||||
format!(
|
||||
"{}:x:{}:{}:User:{}:{}",
|
||||
name.as_ref(),
|
||||
uid,
|
||||
gid,
|
||||
home.as_ref(),
|
||||
shell.as_ref(),
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.map_err(|_| format!("Write to {} failed", passwd))?;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.read(false)
|
||||
.write(true)
|
||||
.append(true)
|
||||
.create(true)
|
||||
.open(group)
|
||||
.map_err(|err| format!("Cannot open: {} {}", group, err))?;
|
||||
|
||||
file.write_all(format!("{}:x:{}:", name.as_ref(), gid,).as_bytes())
|
||||
.map_err(|err| format!("Write to {} failed {}", passwd, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn exec<S: AsRef<str>>(command: S, args: &[S]) -> Result<(), String> {
|
||||
use std::ffi::CString;
|
||||
use nix::unistd::execv;
|
||||
|
||||
let command = command.as_ref();
|
||||
let args = args.iter().map(|arg| arg.as_ref());
|
||||
let args = [command]
|
||||
.iter()
|
||||
.map(|command| *command)
|
||||
.chain(args)
|
||||
.map(|arg| CString::new(arg))
|
||||
.collect::<Result<Vec<CString>, _>>()
|
||||
.map_err(|err| format!("Failed to create CString {}", err))?;
|
||||
let command = CString::new(command).map_err(|err| format!("Failed to create CString {}", err))?;
|
||||
|
||||
execv(command.as_c_str(), args.as_ref()).map_err(|err| format!("execv failed {}", err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod defaults {
|
||||
pub fn uid() -> u32 {
|
||||
5000
|
||||
}
|
||||
pub fn gid() -> u32 {
|
||||
5000
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Configuration<'a> {
|
||||
#[serde(default = "defaults::uid")]
|
||||
gid: u32,
|
||||
#[serde(default = "defaults::gid")]
|
||||
uid: u32,
|
||||
app_ini: Option<&'a str>,
|
||||
app_ini_overwrite: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
{
|
||||
use simplelog::{Config, LevelFilter, TermLogger, TerminalMode};
|
||||
TermLogger::new(LevelFilter::Info, Config::default(), TerminalMode::Mixed)
|
||||
};
|
||||
|
||||
let mut file = File::open("/config.toml").map_err(|err| format!("Failed to open config {}", err))?;
|
||||
let mut str = String::new();
|
||||
file.read_to_string(&mut str).map_err(|err| format!("Failed read config {}", err))?;
|
||||
|
||||
let config = toml::from_str::<Configuration>(str.as_str())?;
|
||||
|
||||
mkdir_chown("/tmp/", config.uid, config.gid)?;
|
||||
mkdir_chown("/data/gitea/", config.uid, config.gid)?;
|
||||
mkdir_chown("/etc/", 0, 0)?;
|
||||
|
||||
adduser("gitea", config.uid, config.gid, "/data/gitea/", "/bin/sh")?; // TODO /bin/sh doesn't exist, we need busybox
|
||||
|
||||
info!("Dropping privileges to config.uid:config.gid");
|
||||
drop_privileges("gitea")?;
|
||||
|
||||
if let Some(app_ini) = config.app_ini {
|
||||
use nix::sys::stat::stat;
|
||||
use std::fs::copy;
|
||||
|
||||
let stat = stat(app_ini);
|
||||
|
||||
if stat.is_err() {
|
||||
copy(app_ini, "/data/gitea/app.ini").map_err(|err| format!("Failed to copy app.ini {}", err))?;
|
||||
} else {
|
||||
if config.app_ini_overwrite {
|
||||
copy(app_ini, "/data/gitea/app.ini").map_err(|err| format!("Failed to copy app.ini {}", err))?;
|
||||
} else {
|
||||
error!("app.ini exists, but not overwriting!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use std::env::set_var;
|
||||
|
||||
set_var("HOME", "/data/gitea/");
|
||||
set_var("GITEA_WORK_DIR", "/data/gitea/");
|
||||
exec("/bin/gitea", &["-c", "/data/gitea/app.ini"])?;
|
||||
|
||||
Ok(())
|
||||
}
|
3
nix-packages/rust-runner/src/main.rs
Normal file
3
nix-packages/rust-runner/src/main.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
Loading…
Reference in a new issue