Configuring Hetzner ARM instances for use with Objective-S

The recent introduction of affordable ARM64 VPS instances by Hetzner ([introduction](URL_PLACEHOLDER_0)) was a welcome development. I had committed to prioritizing ARM for Objective-S (the native compiler is currently ARM64-only), but affordable VPS providers like Digital Ocean were limited to x86. While a mixed ARM/x86 environment is possible, I wanted to avoid the added complexity. This is why I moved the hosting of [Objective-S site](URL_PLACEHOLDER_1) from Digital Ocean to Oracle Cloud’s free tier, as it was the only cost-effective way to host on ARM. With multiple options available, I felt it was the right time to explore a hunch I’d had.

I’ve believed for a while that there’s a need for something between simple shell scripts (like the excellent [Deployment from Scratch](URL_PLACEHOLDER_2)) and the complexity of Kubernetes ([aircraft carrier](URL_PLACEHOLDER_3)). With the infrastructure in place, I decided to pursue this idea by controlling the Hetzner server API using Objective-S.

Interfacing with the Hetzner API

The Hetzner API documentation ([documentation](URL_PLACEHOLDER_4)) reveals that the base URL is https://api.hetzner.cloud/v1/. We can set up an API scheme handler in Objective-S to communicate with the Hetzner API, including the authentication header and JSON support:

1
2
3
4
5
scheme:https setHeaders: #{ 
    #Content-Type: 'application/json'
    #Authorization: "Bearer {keychain:password/hetzner-api/metaobject}",
       }.
scheme:api := ref:https://api.hetzner.cloud/v1 asScheme.

This concise code accomplishes several things: it retrieves the API token from the macOS keychain (keychain:password/hetzner-api/metaobject), inserts it into the Bearer string within a dictionary literal, and defines the api: scheme for interacting with the Hetzner API. For instance, api:servers translates to https://api.hetzner.cloud/v1/servers.

With this setup, we can define a simple class to interact with the API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class HetznerCloud {
   var api.
   -schemeNames { [ 'api' ]. }
   -images {
	api:images.
   }
   -types {
	api:server_types.
   }
} 

This class has two user-facing methods: -images, which lists available images, and -types, which lists server types. The method bodies are brief, leveraging Objective-S’s capabilities. The -schemeNames method enables the api: scheme handler within the class’s methods.

Here’s an example of an interactive st-shell session requesting image types and server types:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
] cloud images
{ "images" = ( { "id" = 3;
"description" = "CentOS 7";
"created_from" = ;
"bound_to" = ;
"rapid_deploy" = true;
"deprecated" = ;
"os_flavor" = "centos";
"type" = "system";
"protection" = { "delete" = false;
} ;
"image_size" = ;
"labels" = { } ;
"deleted" = ;
"architecture" = "x86";
"created" = "2018-01-15T11:34:45+00:00";
"os_version" = "7";
"disk_size" = 5;
"status" = "available";
...
] cloud types
...
{ "memory" = 4;
"prices" = ( { "price_monthly" = { "net" = "3.2900000000";
"gross" = "3.9151000000000000";
} ;
...
} ;
} ) ;
"storage_type" = "local";
"id" = 45;
"cpu_type" = "shared";
"disk" = 40;
"deprecated" = ;
"architecture" = "arm";
"description" = "CAX11";
"name" = "cax11";
"cores" = 2;
}
...
 

The “CAX11” instance type is the entry-level ARM64 instance we’ll be using.

Creating a Server

Creating a VPS involves POSTing a dictionary to the servers endpoint, outlining the desired server properties:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
extension HetznerCloud {
   -baseDefinition {
	#{ 
	    #location: 'fsn1',
	    #public_net: #{
                #enable_ipv4: true,
                #enable_ipv6: false,
           }
	}.
   }
   -armServerDefinition {
	#{
           #name:  'objst-2',
           #image: '103908070',
           #ssh_keys: ['marcel@naraht.local' ],
           #server_type: 'cax11',
	} , self baseDefinition.
   }
   -create {
	  ref:api:servers post: self armServerDefinition  asJSON.
   }
}

The -create method sends the post: message directly to the endpoint reference.

Interacting with Servers

Once a server is created, we’ll want to interact with it, such as deleting it. While this could be done using methods that require a server_id parameter, it’s cleaner to create a separate server abstraction.

The HetznerHost class is initialized with a server response, extracting the IP address and server ID. The latter defines a server: scheme handler. Its subclassing of MPWRemoteHost will become relevant later.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class HetznerHost : MPWRemoteHost {
   var hostDict.
   var id.
   var server.

   +withDictionary:theServer {
	self alloc initWithDictionary:theServer.
   }
   -initWithDictionary:theServer {
       self := super initWithName:(theServer at:'public_net' | at:'ipv4' | at:'ip') user:'root'.
       self setHostDict:theServer.
       self setId: theServer['id'].
       self setServer: ref:api:/servers/{this:id} asScheme.

       self.
     }
     -schemeNames { ['server']. }
     -status { this:hostDict at:'status'. }
     -delete {
         ref:server:/ delete.

     }
}

Similar to the POST request, the DELETE request is handled by sending a delete message to the root reference of the server: scheme.

Retrieving server instances is done via a GET request to the API’s servers endpoint. The collect HOM (Higher Order Messaging) method simplifies mapping from the API’s returned dictionaries to server objects:

1
2
3
4
5
extension HetznerCloud {
   -servers {
	HetznerHost collect withDictionary: (api:servers at:'servers') each.
   }
}

You might be thinking that a dedicated class for servers with its scheme handler seems excessive for just sending a DELETE request. Here are some additional capabilities:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
extension HetznerHost {
     -actions { api:servers/{this:id}/actions value.  }
     -liveStatus { server:status. }
     -refresh {
         self setHostDict: (server:/ value at:'server').
     }
     -shutdown {
         ref:server:actions/shutdown post:#{}.
     }
     -start {
         ref:server:actions/poweron post:#{}.
     }
     -reinstall:osName {
         ref:server:actions/rebuild post: #{ #image: osName }.
     }
     -reinstall {
         self reinstall:'ubuntu-20.04'.
     }
} 

With this, we have complete server lifecycle control with concise and straightforward code, thanks to Objective-S’s abstractions like Polymorphic Identifiers, Storage Combinators, and Higher Order Messaging. This control is available both for scripts and reusable objects in other applications.

Installing Objective-S

With the ability to manage virtual servers, let’s explore using them, for instance, to run Objective-S and Objective-S-based web servers. This is where MPWRemoteHost comes into play. It represents a remote host (currently rudimentary) and facilitates SSH connections for executing commands and transferring files via SFTP. The latter is presented as a store, simplifying file creation on remote hosts:

1
dest:hello.txt := 'Hello world!'.

File copying is similarly straightforward:

1
dest:hello.txt := file:hello.txt.

The script copies a tar archive containing GNUstep and Objective-S libraries, extracting it into the target machine’s '/usr' directory. It also transfers the interactive Objective-S shell (st), the runsite command for serving “.sited” bundles via HTTP, and a .bashrc file for setting up environment variables.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
extension MPWHost { 
 -installObjS {
	scheme:dest := self store.
	filenames := [ 'ObjS-GNUstep-installed.tgz', 'st', '.bashrc', 'runsite' ].
	filenames do: { :filename | 
	     dest:{filename} := file:{filename}.
	}.
	self run:'chmod a+x st runsite';
	     run:'cd /usr ; tar zxf ~/ObjS-GNUstep-installed.tgz';
	     run:'mv st /usr/local/bin';
	     run:'mv runsite /usr/local/bin'.
   }
}
host := MPWHost host:hostip user:'root'.
host installObjS.

As an extension to MPWHost (the superclass of MPWRemoteHost used as the base for HetznerHost), our server objects can now install Objective-S.

The same applies to server objects for similar scripts managing Digital Ocean droplets.

Conclusion

My initial objective wasn’t to showcase Objective-S but to utilize cloud systems effectively. My hunch was that Objective-S would be well-suited for this task. It exceeded my expectations: its features like Polymorphic Identifiers, first-class references, nested scheme handlers, and Higher Order Messaging seamlessly work together for concise and natural interaction with REST APIs and remote hosts. It bridges the gap between ad-hoc scripting and proper modeling, enabling hackability without creating a mess.

Licensed under CC BY-NC-SA 4.0
Last updated on Apr 13, 2023 11:58 +0100