mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-02 04:01:12 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2c923f2fe | ||
|
|
c75009e46f | ||
|
|
43e74ab769 | ||
|
|
1338864ed6 | ||
|
|
70040b633c | ||
|
|
f4bc02d175 | ||
|
|
6c24aa639a | ||
|
|
d8b69de52d | ||
|
|
c4c2bfeded | ||
|
|
b95585b56b | ||
|
|
8d5574ed3f | ||
|
|
457c2bc7db | ||
|
|
4bf9bdd531 | ||
|
|
8e9d61d5f5 |
+2
-1
@@ -83,8 +83,9 @@ task:
|
||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||
install_script:
|
||||
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
|
||||
- brew install go goreleaser/tap/goreleaser-pro
|
||||
- brew install mitchellh/gon/gon
|
||||
- curl -sL https://sentry.io/get-cli/ | sh
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.virtualization</key>
|
||||
<true/>
|
||||
<key>com.apple.security.get-task-allow</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -108,6 +108,10 @@ struct Run: AsyncParsableCommand {
|
||||
@Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\"."))
|
||||
var suspendable: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp("Whether system hot keys should be sent to the guest instead of the host",
|
||||
discussion: "If enabled then system hot keys like Cmd+Tab will be sent to the guest instead of the host."))
|
||||
var captureSystemKeys: Bool = false
|
||||
|
||||
mutating func validate() throws {
|
||||
if vnc && vncExperimental {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
@@ -121,11 +125,21 @@ struct Run: AsyncParsableCommand {
|
||||
throw ValidationError("--graphics and --no-graphics are mutually exclusive")
|
||||
}
|
||||
|
||||
if (noGraphics || vnc || vncExperimental) && captureSystemKeys {
|
||||
throw ValidationError("--captures-system-keys can only be used with the default VM view")
|
||||
}
|
||||
|
||||
let localStorage = VMStorageLocal()
|
||||
let vmDir = try localStorage.open(name)
|
||||
if try vmDir.state() == "suspended" {
|
||||
suspendable = true
|
||||
}
|
||||
|
||||
if suspendable {
|
||||
if dir.count > 0 {
|
||||
throw ValidationError("Suspending VMs with shared directories is not supported")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -298,9 +312,10 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||
if noGraphics || useVNCWithoutGraphics {
|
||||
dispatchMain()
|
||||
// enter the main even loop and just wait for the VM to exit
|
||||
NSApplication.shared.run()
|
||||
} else {
|
||||
runUI(suspendable)
|
||||
runUI(suspendable, captureSystemKeys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +476,7 @@ struct Run: AsyncParsableCommand {
|
||||
return [device]
|
||||
}
|
||||
|
||||
private func runUI(_ suspendable: Bool) {
|
||||
private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool) {
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
@@ -470,13 +485,14 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
struct MainApp: App {
|
||||
static var disappearSignal: Int32 = SIGINT
|
||||
static var capturesSystemKeys: Bool = false
|
||||
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!).onAppear {
|
||||
VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}.onDisappear {
|
||||
let ret = kill(getpid(), MainApp.disappearSignal)
|
||||
@@ -526,6 +542,7 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
|
||||
MainApp.capturesSystemKeys = captureSystemKeys
|
||||
MainApp.main()
|
||||
}
|
||||
}
|
||||
@@ -575,14 +592,12 @@ struct VMView: NSViewRepresentable {
|
||||
typealias NSViewType = VZVirtualMachineView
|
||||
|
||||
@ObservedObject var vm: VM
|
||||
var capturesSystemKeys: Bool
|
||||
|
||||
func makeNSView(context: Context) -> NSViewType {
|
||||
let machineView = VZVirtualMachineView()
|
||||
|
||||
// Do not capture system keys so that shortcuts like
|
||||
// Shift-Command-4 + Space (capture a screenshot of window)
|
||||
// work on the host instead of the guest
|
||||
machineView.capturesSystemKeys = false
|
||||
machineView.capturesSystemKeys = capturesSystemKeys
|
||||
|
||||
// Enable automatic display reconfiguration
|
||||
// for guests that support it
|
||||
|
||||
@@ -35,12 +35,12 @@ class Fetcher {
|
||||
//
|
||||
// This keeps a working reference to that file, yet we don't
|
||||
// have to deal with the cleanup any more.
|
||||
let fh = try FileHandle(forReadingFrom: fileURL)
|
||||
let mappedFile = try Data(contentsOf: fileURL, options: [.alwaysMapped])
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
|
||||
Task {
|
||||
while let data = try fh.read(upToCount: 64 * 1024 * 1024) {
|
||||
await dataCh.send(data)
|
||||
for chunk in (0 ..< mappedFile.count).chunks(ofCount: 64 * 1024 * 1024) {
|
||||
await dataCh.send(mappedFile.subdata(in: chunk))
|
||||
}
|
||||
|
||||
dataCh.finish()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
actor AuthenticationKeeper {
|
||||
var authentication: Authentication? = nil
|
||||
|
||||
func set(_ authentication: Authentication) {
|
||||
self.authentication = authentication
|
||||
}
|
||||
|
||||
func header() -> (String, String)? {
|
||||
if let authentication = authentication {
|
||||
// Do not suggest any headers if the
|
||||
// authentication token has expired
|
||||
if !authentication.isValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return authentication.header()
|
||||
}
|
||||
|
||||
// Do not suggest any headers if the
|
||||
// authentication token is not set
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,16 @@ class DiskV2: Disk {
|
||||
var pushedLayers: [OCIManifestLayer] = []
|
||||
|
||||
// Open the disk file
|
||||
let disk = try FileHandle(forReadingFrom: diskURL)
|
||||
var mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
|
||||
// Compress the disk file as multiple individually decompressible streams,
|
||||
// each equal ``Self.layerLimitBytes`` bytes or slightly larger due to the
|
||||
// internal compressor's buffer
|
||||
while let (compressedData, uncompressedSize, uncompressedDigest) = try compressNextLayerOfLimitBytesOrMore(disk: disk) {
|
||||
var offset: UInt64 = 0
|
||||
|
||||
while let (compressedData, uncompressedSize, uncompressedDigest) = try compressNextLayerOfLimitBytesOrMore(mappedDisk: mappedDisk, offset: offset) {
|
||||
offset += uncompressedSize
|
||||
|
||||
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||
|
||||
pushedLayers.append(OCIManifestLayer(
|
||||
@@ -106,6 +110,8 @@ class DiskV2: Disk {
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
}
|
||||
|
||||
try filter.finalize()
|
||||
|
||||
try disk.close()
|
||||
}
|
||||
|
||||
@@ -114,7 +120,7 @@ class DiskV2: Disk {
|
||||
}
|
||||
}
|
||||
|
||||
private static func compressNextLayerOfLimitBytesOrMore(disk: FileHandle) throws -> (Data, UInt64, String)? {
|
||||
private static func compressNextLayerOfLimitBytesOrMore(mappedDisk: Data, offset: UInt64) throws -> (Data, UInt64, String)? {
|
||||
var compressedData = Data()
|
||||
var bytesRead: UInt64 = 0
|
||||
let digest = Digest()
|
||||
@@ -126,10 +132,15 @@ class DiskV2: Disk {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let uncompressedChunk = try disk.read(upToCount: bufferSizeBytes) else {
|
||||
let readFromByte = Int(offset + bytesRead)
|
||||
|
||||
let numBytesToRead = min(mappedDisk.count - readFromByte, bufferSizeBytes)
|
||||
if numBytesToRead == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
let uncompressedChunk = mappedDisk.subdata(in: readFromByte ..< (readFromByte + numBytesToRead))
|
||||
|
||||
bytesRead += UInt64(uncompressedChunk.count)
|
||||
digest.update(uncompressedChunk)
|
||||
|
||||
|
||||
@@ -102,8 +102,7 @@ class Registry {
|
||||
private let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProviders: [CredentialsProvider]
|
||||
|
||||
var currentAuthToken: Authentication? = nil
|
||||
let authenticationKeeper = AuthenticationKeeper()
|
||||
|
||||
var host: String? {
|
||||
guard let host = baseURL.host else { return nil }
|
||||
@@ -253,7 +252,7 @@ class Registry {
|
||||
for try await part in channel {
|
||||
try Task.checkCancellation()
|
||||
|
||||
try await handler(Data(part))
|
||||
try await handler(part)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,11 +304,6 @@ class Registry {
|
||||
request.httpBody = body
|
||||
}
|
||||
|
||||
// Invalidate token if it has expired
|
||||
if currentAuthToken?.isValid() == false {
|
||||
currentAuthToken = nil
|
||||
}
|
||||
|
||||
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||
|
||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||
@@ -331,7 +325,7 @@ class Registry {
|
||||
|
||||
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
||||
if let (user, password) = try lookupCredentials() {
|
||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
||||
await authenticationKeeper.set(BasicAuthentication(user: user, password: password))
|
||||
}
|
||||
|
||||
return
|
||||
@@ -378,7 +372,7 @@ class Registry {
|
||||
+ "while retrieving an authentication token", details: data.asText())
|
||||
}
|
||||
|
||||
currentAuthToken = try TokenResponse.parse(fromData: data)
|
||||
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
|
||||
}
|
||||
|
||||
private func lookupCredentials() throws -> (String, String)? {
|
||||
@@ -399,8 +393,7 @@ class Registry {
|
||||
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
var request = request
|
||||
|
||||
if let token = currentAuthToken {
|
||||
let (name, value) = token.header()
|
||||
if let (name, value) = await authenticationKeeper.header() {
|
||||
request.addValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ struct Darwin: PlatformSuspendable {
|
||||
let result = VZMacPlatformConfiguration()
|
||||
|
||||
result.machineIdentifier = ecid
|
||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: nvramURL)
|
||||
|
||||
if !hardwareModel.isSupported {
|
||||
// At the moment support of M1 chip is not yet dropped in any macOS version
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 602 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 602 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 538 KiB |
@@ -0,0 +1,71 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2023-10-06
|
||||
search:
|
||||
exclude: true
|
||||
authors:
|
||||
- fkorotkov
|
||||
categories:
|
||||
- announcement
|
||||
---
|
||||
|
||||
# Tart is now available on AWS Marketplace
|
||||
|
||||
Announcing [official AMIs for EC2 Mac Instances](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws)
|
||||
with preconfigured Tart installation that is optimized to work within AWS infrastructure.
|
||||
|
||||
EC2 Mac Instances is a gem of engineering powered by AWS Nitro devices. Just imagine there is a physical Mac Mini with
|
||||
a plugged in Nitro device that can push the physical power button!
|
||||
|
||||

|
||||
|
||||
This clever synergy between Apple Hardware and Nitro System allows seamless integration with VPC networking and booting macOS from an EBS volume.
|
||||
|
||||
In this blog post we’ll see how a virtualization solution like Tart can compliment and elevate experience with EC2 Mac Instances.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
Let’s start from the basics, what EC2 Mac Instances allow to do compared to physical Mac Minis seating in offices of
|
||||
many companies around the world?
|
||||
|
||||
First and foremost, EC2 Mac Instances sit inside AWS data centers and can leverage all the goodies of VPC networking
|
||||
within your company's existing infrastructure. No need to connect your Macs in the office through a VPN and deal
|
||||
with networking and security.
|
||||
|
||||
Additionally, EC2 Mac Instances are booting from EBS volumes which means it is possible to always have reproducible instances
|
||||
and apply all the best practices of Infrastructure-as-Code. Managing a fleet of physical Macs is a pain and it's very hard
|
||||
to make them configured in a reproducible and stable way. With booting from identical EBS volumes your team is always sure
|
||||
about the identical initial state of the fleet.
|
||||
|
||||
## Compromises of EC2 Mac Instances
|
||||
|
||||
The flexibility of EBS volumes for macOS comes with some compromises that virtualization solutions like Tart can help with.
|
||||
The initial boot from an EBS volume takes some time and not instant. macOS itself is pretty heavy and a Nitro device needs
|
||||
to download tens of gigabytes that macOS requires in order to boot. This means that **resetting a EC2 Mac Instance to a clean state
|
||||
is not instant and usually takes a couple of minutes** when you can’t utilize the precious resources for your workloads.
|
||||
|
||||
It is much easier to tailor such EBS volumes with tools like Packer but there is still a **friction to test newly created EBS volumes**
|
||||
since one needs to start and run a EC2 Mac Instance and it’s not possible to test things locally. Similarly it is even harder
|
||||
to test beta versions of macOS that require manual interaction with a running instance.
|
||||
|
||||
## Solution
|
||||
|
||||
Tart can help with all the compromises! Tart virtual machines (VMs) have nearly native performance thanks to utilizing
|
||||
native `Virtualization.Framework` that was developed along the first Apple Silicon chip. **Tart VMs can be copied/disposed
|
||||
instantly and booting a fresh Tart VM takes only several seconds**. It is also possible to run two different Tart VMs in parallel
|
||||
that can have completely different versions of macOS and packages. For example, it is possible to have the latest stable macOS
|
||||
with the release version of Xcode along with the next version of macOS with the latest beta of Xcode.
|
||||
|
||||
Creation of Tart VMs can be automated with [a Packer plugin](https://github.com/cirruslabs/packer-plugin-tart) the same way as
|
||||
creation of EC2 AMIs with one caveat that **Tart Packer Plugin works locally so you can test the same virtual machine locally
|
||||
as you would run it in the cloud**.
|
||||
|
||||
Lightweight nature of Tart VMs with a focus on an easy-to-integrate Tart CLI compliments any macOS automation and helps to reduce
|
||||
the feedback cycle and improves reproducibility of macOS environments even further.
|
||||
|
||||
## Conclusion
|
||||
|
||||
We are excited to bring [official AMIs that include Tart installation optimized to work within AWS](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws).
|
||||
In the coming weeks when macOS Sonoma will become available on AWS we’ll release another update specifically targeting EC2 Mac Instances.
|
||||
This update will simplify access to local SSDs of Mac Instances that are slightly faster than EBS volumes. Stay tuned and don’t hesitate
|
||||
to ask any [questions](https://tart.run/licensing/).
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2023-11-03
|
||||
search:
|
||||
exclude: true
|
||||
authors:
|
||||
- fkorotkov
|
||||
categories:
|
||||
- announcement
|
||||
---
|
||||
|
||||
# New dashboard with insights into performance of Cirrus Runners
|
||||
|
||||
This month we are celebrating one year since launching Cirrus Runners — managed Apple Silicon infrastructure for your
|
||||
GitHub Actions. During the last 12 months we ran millions of workflows for our customers and now ready to share some insights
|
||||
into price performance of them for our customers.
|
||||
|
||||
One of the key difference with Cirrus Runners is how they are getting billed for. Customers purchase Cirrus Runners via monthly subscription
|
||||
that costs $150 per each Cirrus Runner. Each runner can be used 24 hours a day 7 days a week to run GitHub Actions workflows
|
||||
for an organization. If there are more outstanding jobs than available runners then they are queued and executed as soon as
|
||||
there is a free runner. This is different from how GitHub-managed GitHub Actions are billed for — you pay for each minute of execution time.
|
||||
|
||||
The benefit of a fixed price is that you can run as many jobs as you want without worrying about the cost. The downside is that
|
||||
you need to make sure that you are using your runners efficiently. This is where the new dashboard comes in handy.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
But first, **let's see theoretically the lowest price per minute** of a Cirrus Runners. If you run 24 hours a day 7 days a week
|
||||
then you will get 43,200 minutes of execution time per month. This means that the price per minute is $0.0035 if your runners
|
||||
utilization is 100%. But even if your engineering teams is located in a single time zone and works 8 hours a day 5 days a week
|
||||
then you will get 9,600 minutes of execution time per month which comes down to $0.015 per-minute. This is still more than 10 times cheaper
|
||||
than recently announced Apple Silicon GitHub-manged runners that cost $0.16 per minute.
|
||||
|
||||
Now lets take a look at the new Cirrus Runners dashboard of a real customers that run their workflows on Cirrus Runners
|
||||
and **practically pushing the price performance pretty close to the theoretical minimum**.
|
||||
|
||||

|
||||
|
||||
As you can see above Cirrus Runners Dashboard focuses on 4 core metrics:
|
||||
|
||||
1. **Minutes Used** — overall amount of minutes that Cirrus Runners were executing jobs.
|
||||
2. **Workflow Runs** — absolute number of workflow runs that were executed on Cirrus Runners.
|
||||
3. **Queue Size** — number of jobs that were queued and waiting for a free Cirrus Runner.
|
||||
4. **Queue Time** — average time that jobs were waiting in the queue.
|
||||
|
||||
In this particular example price performance of Cirrus Runners is $0.006 per minute which is 2 times more than the theoretical minimum
|
||||
and **26 times better than GitHub-managed Apple Silicon runners**. But this is a extreme example, looking at queue time and queue size
|
||||
we can see that the downside of such great price performance is that jobs are waiting in the queue on average around 5 minutes.
|
||||
|
||||
Here is another example of Cirrus Runners Dashboard for a different customer that has a slightly higher price performance of $0.017 per minute
|
||||
but at the same time doesn't experience queue time at all. **Note that $0.017 is still 10 times cheaper than GitHub-managed Apple Silicon runners**.
|
||||
|
||||

|
||||
|
||||
## Conclusion
|
||||
|
||||
Having a fixed price for Cirrus Runners is a great way to save money on your CI/CD infrastructure and just in general have predictable budged.
|
||||
But it requires keeping the balance between price per minute and queue time. Cirrus Runners Dashboard helps you to keep an eye on this balance
|
||||
and make sure that you are getting the most out of your Cirrus Runners.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Cirrus Runners for GitHub Actions
|
||||
|
||||
*Cirrus Runners* is the fastest way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
|
||||
Your actions will be executed in clean macOS virtual machines with 4 Apple M2 cores, compared to GitHub's own macOS runners with just 3 cores and only supporting the outdated Apple–Intel architecture.
|
||||
*Cirrus Runners* is the fastest and most cost-efficient way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
|
||||
Your actions will be executed in clean macOS virtual machines with 4 Apple M2 cores.
|
||||
|
||||
## Testimonials from customers
|
||||
|
||||
@@ -84,6 +84,14 @@ Note that Cirrus Runners will get added to the default runner group.
|
||||
|
||||

|
||||
|
||||
### Dashboard
|
||||
|
||||
You can also see the status of your runners on the [Cirrus Runners Dashboard](https://cirrus-runners.app/). This dashboard
|
||||
also provides insights into price performance of your Cirrus Runners. Please check out [this blog post](/blog/2023/11/03/new-dashboard-with-insights-into-performance-of-cirrus-runners/)
|
||||
to learn more about what this dashboard can do for you.
|
||||
|
||||

|
||||
|
||||
## Data handling flow
|
||||
|
||||
By design Cirrus Runners service never sees any of your secrets or source code and acts as compute platform with the lastest
|
||||
|
||||
@@ -19,7 +19,7 @@ concurrent = 2
|
||||
[[runners]]
|
||||
# ...
|
||||
executor = "custom"
|
||||
builds_dir = "/Users/admin/builds" # directory inside the
|
||||
builds_dir = "/Users/admin/builds" # directory inside the VM
|
||||
cache_dir = "/Users/admin/cache"
|
||||
[runners.feature_flags]
|
||||
FF_RESOLVE_FULL_TLS_CHAIN = false
|
||||
|
||||
@@ -56,6 +56,11 @@ If your organization is interested in purchasing one of the license tiers, pleas
|
||||
|
||||
You can see a template of a license subscription agreement [here](assets/TartLicenseSubscription.pdf).
|
||||
|
||||
!!! info "Running on AWS?"
|
||||
|
||||
There are [official AMIs for EC2 Mac Instances](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws)
|
||||
with preconfigured Tart installation that is optimized to work within AWS infrastructure.
|
||||
|
||||
# General Support
|
||||
|
||||
The best way to ask general questions about particular use cases is to email our support team at [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||
|
||||
Vendored
+5
-8
@@ -1,11 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block announce %}
|
||||
<a href="/blog/2023/09/20/tart-200-and-community-updates/">
|
||||
🚀🚀🚀  Announcing <strong>Tart 2.0.0</strong> 🚀🚀🚀
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
<!-- Render landing page under tabs -->
|
||||
{% block tabs %} {{ super() }}
|
||||
|
||||
@@ -242,12 +236,15 @@
|
||||
}
|
||||
let counterElement = document.getElementById('installation-counter');
|
||||
if (counterElement) {
|
||||
counterElement.textContent = Math.round(allDownloads / 1000) + ",000"
|
||||
// Live installation count is available starting version 1.0.0
|
||||
// Prior Tart was installed a little over 14,000 times, let's count them too
|
||||
let installationPriorV1 = 14
|
||||
counterElement.textContent = (installationPriorV1 + Math.round(allDownloads / 1000)) + ",000"
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<h2>
|
||||
With more than <strong id="installation-counter">10,000</strong> installations to date, Tart has been adopted for various scenarios.
|
||||
With more than <strong id="installation-counter">25,000</strong> installations to date, Tart has been adopted for various scenarios.
|
||||
Its applications range from powering CI/CD pipelines and reproducible local development environments,
|
||||
to helping in the testing of device management systems without actual physical devices.
|
||||
</h2>
|
||||
|
||||
Reference in New Issue
Block a user